Repository navigation
Conversation
Add Client.Emails(), a port of posthog.emails from posthog-node and
posthog-python. Send takes a TemplateEmail (a template made in PostHog,
by ID) or a CodeEmail (content the app rendered, named by a key), posts
the flat body to /api/projects/@current/messaging/send/email/ on the app
host with the project secret key, and retries with one idempotency key.
Get reads /api/projects/@current/messaging/emails/{id}/. Close waits for
email requests in flight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One request method sends, retries and classifies a reply that isn't a 2xx, so Send and Get parse a successful reply once. The Close test checks the retry reached the server before Close returned, instead of a race on the result channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng bytes - Send refuses a nil *TemplateEmail or *CodeEmail with invalid_form instead of panicking. - When the context ends during a retry wait, the error wraps ctx.Err(), so errors.Is finds context.DeadlineExceeded. - A reply that isn't JSON as a whole carries no fields, so a 2xx with trailing bytes is an api_error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
goccy/go-json's MarshalNoEscape hides its argument from escape analysis, so a body built on the stack can move while it encodes. The Go 1.21 race tests caught it reading the template body as garbage and panicking. The email bodies now use the standard library encoder with HTML escaping off, and a test pins the HTML as written on the wire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dropped connection after a 409's headers lost idempotency_key_in_flight and became a non-retryable api_error. A failed body read now is a network error for every status, so the client retries it with the same idempotency key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CodeEmail takes Cc, Bcc and Attachments, matching posthog-node (PostHog/posthog-js#5514) and posthog-python (PostHog/posthog-python#1172) and the server contract in PostHog/posthog#127988. An EmailAttachment carries the file's bytes, which encoding/json sends as standard Base64. An attachment without a Filename or Content fails with invalid_attachment before any request, and empty lists stay out of the body, so a server that doesn't take a field yet only sees it when the send uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-go Compliance ReportDate: 2026-11-06 08:10:45 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
- Cap EmailRequestMaxRetries and a send's MaxRetries at 9, as MaxRetries and FeatureFlagRequestMaxRetries are, so one send can't retry for hours and hold Close. - Match a PostHog Cloud endpoint written with upper case or the default :443 port to its app host. - Keep Retry-After on a 2xx answer that lacks its message ID or status. - Check UUIDs with google/uuid instead of a second pattern. - Merge the caller's context and the shutdown context once per request, which removes the separate shutdown branch of the retry wait. - Move the reasons in function bodies into named helpers, and correct the AppHost doc: SecretKey also feeds local flag evaluation on Endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EmailAttachment carries its own JSON names, so the code send encodes it directly. A blank Filename is refused like a blank address. The test for a field PostHog doesn't take yet replays the reply PostHog/posthog#127123 gives for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
| return newEmailSend(body, e.IdempotencyKey, e.MaxRetries) | ||
| } | ||
|
|
||
| func checkAttachments(attachments []EmailAttachment) *EmailError { |
There was a problem hiding this comment.
Low: Bound attachments before encoding
Any nonempty Content passes this check regardless of size, and prepare buffers the entire Base64-encoded JSON body before HTTP timeouts or server-side rejection can apply. Enforce the documented 10-attachment and 512 KB aggregate limits here before encoding to bound client-side memory use when applications forward user-supplied files; no upload-facing caller is shown in this repository.
There was a problem hiding this comment.
Thanks for the look. Leaving this one to the server, on purpose:
- PostHog owns the 10-file and 512 KB limits and refuses past them before any mail goes out. The live run against feat(messaging): send cc, bcc and attachments on a code send posthog#127988 got a 400 for 11 attachments and for 600 KB in total.
- A copy of those limits in the SDK would drift the day the server changes them, and an SDK already in users' apps would then refuse sends the server takes. posthog-node (feat(node): send cc, bcc and attachments from code posthog-js#5514) and posthog-python (feat: send cc, bcc and attachments with emails.send posthog-python#1172) leave the limits to the server too.
- The caller already holds the bytes in
Content, so the client adds one Base64 copy, about a third larger. An app that forwards user uploads bounds them where it reads the upload, before it holds them in memory at all.
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
PR overviewThe PR adds support for CC recipients, BCC recipients, and attachments when sending emails from code. One open issue remains: attachment count and aggregate size are not checked before the client buffers the Base64-encoded request, allowing oversized inputs to cause excessive memory use. Exploitation depends on an application forwarding user-supplied files, and no upload-facing caller is shown in this repository. Enforcing the documented 10-attachment and 512 KB aggregate limits before encoding would address this hardening gap. Open issues (1)
Fixed/addressed: 0 · PR risk: 3/10 |
#406 landed on main as a squash, with its last fixes (transactional_email scopes, https app host, secret-bearing flag requests to the app host). This branch now differs from main by its own change only: Cc, Bcc and Attachments on a CodeEmail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-add the stamphog label to request another review once you have addressed this.
Current-head review and STRONG author familiarity provide assurance for the risky changes, but @veria-ai's substantive memory-use concern remains open. Add pre-encoding attachment bounds or obtain a maintainer's explicit acceptance in the emails.go thread before re-requesting.
- Author wrote 100% of the modified lines and has 1 merged PRs in these paths (familiarity STRONG).
- PostHog Review (Standard) reviewed the current head: no findings.
- posthog[bot] reviewed the current head.
- @veria-ai on emails.go: attachment validation checks only filename and nonempty content, with no count or aggregate-size limit before encodeEmailBody buffers the entire encoded request. The memory-use concern remains unfixed, and the reviewer explicitly left the thread open for a maintainer decision.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 81L, 1F substantive, 217L/5F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1c-medium (217L, 5F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 20f7605 · reviewed head 20f7605 |
💡 Motivation and Context
A Go backend that sends email from code can't attach a receipt PDF or copy another address, so a team moving its receipts and calendar invites to PostHog has to keep a second email provider for them. posthog-node (#5514) and posthog-python (#1172) add these fields, and PostHog/posthog#127988 adds them to the server's code send. This PR brings posthog-go to the same shape. It builds on #406, now on main, which adds
Client.Emails(). Part of map Silthus/posthog#461.Caution
Merge risk: medium, one-way door once released.
CodeEmail.Cc,.Bcc,.Attachmentsand theEmailAttachmenttype part of the public contract.What changes
CodeEmailgainsCc,BccandAttachments.CcandBccare lists of bare addresses (at most 10 each, checked by PostHog). The wire names arecc,bccandattachments, as in the contract.EmailAttachment{Filename, Content, ContentType, ContentID}.Contentis the file's bytes;encoding/jsonsends a[]byteas standard Base64, the encoding PostHog reads, so the client needs no encoder of its own.ContentIDshows the file inline ascid:<ContentID>in the HTML.CcorBccaddress (invalid_cc,invalid_bcc, asReplyToalready does), and an attachment with a blankFilenameor noContent(invalid_attachment, withFieldsuch asAttachments[1].Content). The error never holds the file's content..exe, the MIME type, the content ID and the address format. Its refusals reach the caller as*EmailErrorwith Codeinvalid_request, the field inField(such asattachments__0__filenameorbcc__0), and PostHog's message,WhyandFix, as for every other field.TemplateEmailhas no such fields, so a template send can't carry them: the compiler refuses what Node and Python refuse at run time.Examplefor a receipt with a copy and a PDF, the changeset (minor), and the regeneratedapi/public-api.txt.💚 How did you test it?
Tests at the public seam,
Client.Emails().Sendon a real client against anhttptestserver: the wire body of copies and attachments (Base64 checked against fixed values, one of them binary), the same body on a retry, empty lists left out, each refusal before a request, the error never holding the file's content, and the reply a server without these fields gives, as recorded below, surfacing that field without a retry.Gate at
2e2458cpasses:gofmt -l,go mod tidywith no diff,go vet ./...,go test -race ./...(Go 1.21.13, the module's floor),bin/api-diff, and theotelandposthogmcpsdkmodules.Live run against a local PostHog stack: Django at PostHog/posthog#127988 (
e09eec3), cdp-api and the email and hogflow workers at PostHog/posthog#127986 (a239b6b, the top of #127985 → #127995 → #127986), and maildev. A Go program drove this branch (tree93daa69, which differs from2e2458conly in one test stub string) through a recording proxy:invoice.pdf,logo.pnginline ascid:logoBcc:header, and neither Bcc address appears in any header. Both files arrive byte for byte; the logo is inline withContent-ID: <logo>.invoice.exeinvalid_request,Fieldattachments__0__filename: "Mailbox providers block.exefiles." No mail.invalid_request,Fieldattachments: "Send at most 10 attachments." No mail.invalid_request,Fieldattachments: at most 512 KB in total. No mail.Jane <jane@…>invalid_request,Fieldbcc__0: one address with no name. No mail.Filenameinvalid_attachment,FieldAttachments[0].Filename. No request.Cross-version run against the server #406 ships against, without these fields: Django at PostHog/posthog#127123 (
213fad6), CDP at PostHog/posthog#127122 (eb26545). A send withCcor with an attachment gets a 400{"type": "validation_error", "code": "invalid_input", "detail": "PostHog doesn't take this field, so it doesn't send the email without it.", "attr": "cc"}(andattachments), and no mail goes out. A plain code send still delivers. So an SDK released before the server can't drop a Bcc copy or an attachment without an error.Shadow diff against #406's head
80ee4a5: 26 inputs outside emails (capture, identify, alias, groups, everyEnqueuetype, flags remote and local,Flush,Close) and 7 email inputs (template and code sends without the new fields, refused sends,Get) give the same requests, results, errors and log lines. A plantedccwithoutomitemptyshows up in the diff.The server PRs have moved since the runs. Their new commits add refusal rules, accept an empty list and polish the error body; none changes the field names, the Base64 content or the reply shape this client relies on. The raw request and response pairs, the maildev readbacks and the drivers are saved with
fleet proof.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), as the posthog-go slice of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, directed by Michael.Contentis[]byteonly. Node and Python also take a Base64 string; in Go a caller holding Base64 decodes it withbase64.StdEncoding.DecodeString, and one type keeps the field unambiguous. Client-side field names follow the Go names (Attachments[0].Filename), asReplyToalready does; PostHog's own refusals keep itsattachments__0__filename.Fieldkeeps two spellings by design: a client-side refusal names the Go field you wrote (Attachments[0].Filename), and a PostHog refusal passes its ownattrthrough (attachments__0__filename), as feat(emails): send transactional email and read its status #406 does forReplyTo. Rewriting PostHog's paths would tie the client to the server's path format.2e2458c: one attachment type instead of a duplicate wire type, a blankFilenamerefused like a blank address, the older-server test replaying the recorded reply, one comment dropped. Rejected: oneFieldvocabulary (above). The shadow diff the risk lens asked for ran clean. Follow-up for sdk-specs: spec coverage for code sends and these fields.20f7605merges main in. The branch's own diff against main is the same as before, line for line, and the posthog-go lane gate passes at20f7605. veria-ai's ask to cap attachment count and size in the client is rejected: PostHog enforces both limits before any mail goes out (live run c1, c2), and Node and Python leave them to the server too.🤖 Generated with Claude Code