Skip to content

feat(emails): send cc, bcc and attachments on a code send - #408

Open
Silthus wants to merge 14 commits into
mainfrom
feat/emails-code-fields
Open

Silthus wants to merge 14 commits into
mainfrom
feat/emails-code-fields

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

💡 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.

_, err = client.Emails().Send(ctx, posthog.CodeEmail{
	To:          "user@example.com",
	TemplateKey: "receipt",
	Subject:     "Your receipt",
	HTML:        renderedHTML,
	Cc:          []string{"finance@example.com"},
	Attachments: []posthog.EmailAttachment{
		{Filename: "receipt.pdf", Content: receiptPDF, ContentType: "application/pdf"},
	},
})

Caution

Merge risk: medium, one-way door once released.

What changes

  • CodeEmail gains Cc, Bcc and Attachments. Cc and Bcc are lists of bare addresses (at most 10 each, checked by PostHog). The wire names are cc, bcc and attachments, as in the contract.
  • EmailAttachment{Filename, Content, ContentType, ContentID}. Content is the file's bytes; encoding/json sends a []byte as standard Base64, the encoding PostHog reads, so the client needs no encoder of its own. ContentID shows the file inline as cid:<ContentID> in the HTML.
  • Checked before any request: a blank Cc or Bcc address (invalid_cc, invalid_bcc, as ReplyTo already does), and an attachment with a blank Filename or no Content (invalid_attachment, with Field such as Attachments[1].Content). The error never holds the file's content.
  • PostHog checks the rest: at most 10 attachments and 512 KB in total, blocked file types such as .exe, the MIME type, the content ID and the address format. Its refusals reach the caller as *EmailError with Code invalid_request, the field in Field (such as attachments__0__filename or bcc__0), and PostHog's message, Why and Fix, as for every other field.
  • Empty lists stay out of the body, so a server that doesn't take a field yet only sees it when a send uses it.
  • TemplateEmail has no such fields, so a template send can't carry them: the compiler refuses what Node and Python refuse at run time.
  • An Example for a receipt with a copy and a PDF, the changeset (minor), and the regenerated api/public-api.txt.

💚 How did you test it?

Tests at the public seam, Client.Emails().Send on a real client against an httptest server: 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 2e2458c passes: gofmt -l, go mod tidy with no diff, go vet ./..., go test -race ./... (Go 1.21.13, the module's floor), bin/api-diff, and the otel and posthogmcpsdk modules.

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 (tree 93daa69, which differs from 2e2458c only in one test stub string) through a recording proxy:

Case Result
Cc one address, Bcc two, invoice.pdf, logo.png inline as cid:logo 202. One message to To, Cc and both Bcc. No Bcc: header, and neither Bcc address appears in any header. Both files arrive byte for byte; the logo is inline with Content-ID: <logo>.
An attachment named invoice.exe 400 invalid_request, Field attachments__0__filename: "Mailbox providers block .exe files." No mail.
11 attachments 400 invalid_request, Field attachments: "Send at most 10 attachments." No mail.
Two 300 KB attachments 400 invalid_request, Field attachments: at most 512 KB in total. No mail.
Bcc Jane <jane@…> 400 invalid_request, Field bcc__0: one address with no name. No mail.
An attachment without a Filename Refused by the client with invalid_attachment, Field Attachments[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 with Cc or 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"} (and attachments), 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, every Enqueue type, 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 planted cc without omitempty shows 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

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written by Claude Code, Claude Opus 5.5 (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.
  • Calls made: Content is []byte only. Node and Python also take a Base64 string; in Go a caller holding Base64 decodes it with base64.StdEncoding.DecodeString, and one type keeps the field unambiguous. Client-side field names follow the Go names (Attachments[0].Filename), as ReplyTo already does; PostHog's own refusals keep its attachments__0__filename.
  • Field keeps two spellings by design: a client-side refusal names the Go field you wrote (Attachments[0].Filename), and a PostHog refusal passes its own attr through (attachments__0__filename), as feat(emails): send transactional email and read its status #406 does for ReplyTo. Rewriting PostHog's paths would tie the client to the server's path format.
  • Review: a QA swarm panel of eight lenses (Claude fresh eyes; correctness, risk, design, anti-overengineering, sweep, sdk-spec conformance and michael on GPT-6.1 Sol). Fixed in 2e2458c: one attachment type instead of a duplicate wire type, a blank Filename refused like a blank address, the older-server test replaying the recorded reply, one comment dropped. Rejected: one Field vocabulary (above). The shadow diff the risk lens asked for ran clean. Follow-up for sdk-specs: spec coverage for code sends and these fields.
  • After feat(emails): send transactional email and read its status #406 landed on main as a squash, 20f7605 merges main in. The branch's own diff against main is the same as before, line for line, and the posthog-go lane gate passes at 20f7605. 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

Silthus and others added 9 commits November 2, 2026 15:49
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>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-go Compliance Report

Date: 2026-11-06 08:10:45 UTC
Duration: 168996ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

✅ 94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 17ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 7ms
Required Headers.Has Authorization Bearer Header ✅ 16ms
Required Headers.Has Content Type Json ✅ 5ms
Required Headers.Has Posthog Sdk Info Format ✅ 5ms
Required Headers.Has Posthog Attempt Header ✅ 4ms
Required Headers.Has Posthog Request Id ✅ 5ms
Required Headers.Has Posthog Request Timestamp ✅ 5ms
Required Headers.Has User Agent ✅ 5ms
Body Format.Body Has Created At And Batch ✅ 4ms
Body Format.No Api Key In Body ✅ 5ms
Body Format.No Sent At In Body ✅ 4ms
Event Format.Event Has Required Root Fields ✅ 5ms
Event Format.Event Uuid Is Valid ✅ 5ms
Event Format.Event Timestamp Is Rfc3339 ✅ 5ms
Event Format.Distinct Id Is String ✅ 4ms
Event Format.Distinct Id At Root Not Properties ✅ 5ms
Event Format.Custom Properties Preserved ✅ 5ms
Event Format.Set Properties Preserved ✅ 4ms
Event Format.Set Once Properties Preserved ✅ 6ms
Event Format.Groups Properties Preserved ✅ 4ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 4ms
Event Format.Event Has Required Root Fields Batch ✅ 7ms
Event Format.Event Uuid Is Valid Batch ✅ 6ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 7ms
Event Format.Distinct Id Is String Batch ✅ 7ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 7ms
Event Format.Custom Properties Preserved Batch ✅ 6ms
Event Format.Set Properties Preserved Batch ✅ 7ms
Event Format.Set Once Properties Preserved Batch ✅ 7ms
Event Format.Groups Properties Preserved Batch ✅ 7ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 6ms
Batch Behavior.Multiple Events In Single Batch ✅ 29ms
Batch Behavior.Batch Envelope Smoke ✅ 9ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 3ms
Batch Behavior.Flush At Triggers Batch ✅ 1006ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 16ms
Deduplication.Generates Unique Uuids ✅ 12ms
Deduplication.Different Events Same Content Different Uuids ✅ 6ms
Deduplication.Preserves Uuid On Retry ✅ 5132ms
Deduplication.Preserves Timestamp On Retry ✅ 5134ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5136ms
Deduplication.No Duplicate Events In Batch ✅ 30ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 5ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 10138ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5134ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 2012ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 5132ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 6ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 27ms
Response Format Validation.Success No Retry After When All Ok ✅ 26ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 128ms
Response Format Validation.Success No Retry After When Drop Only ✅ 27ms
Response Format Validation.Response Echoes Request Id ✅ 5ms
Retry Behavior.Retries On 408 ✅ 5133ms
Retry Behavior.Retries On 500 ✅ 5133ms
Retry Behavior.Retries On 503 ✅ 5133ms
Retry Behavior.Retries On 504 ✅ 5133ms
Retry Behavior.Retryable Errors Have Retry After ✅ 2132ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 8137ms
Retry Behavior.Does Not Retry On 400 ✅ 2131ms
Retry Behavior.Does Not Retry On 401 ✅ 2131ms
Retry Behavior.Does Not Retry On 402 ✅ 2131ms
Retry Behavior.Does Not Retry On 413 ✅ 2131ms
Retry Behavior.Does Not Retry On 415 ✅ 2131ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2131ms
Retry Behavior.Implements Backoff ✅ 15135ms
Retry Behavior.Max Retries Respected ✅ 15144ms
Partial Batch Handling.Handles 200 Full Success ✅ 2008ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3031ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3030ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3030ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 5132ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 5134ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 5135ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 5134ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 5133ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 5134ms
Partial Batch Handling.Respects Retry After On Partial ✅ 5135ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3030ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3032ms
Compression.Sends Gzip Content Encoding ✅ 15ms
Compression.No Content Encoding When Disabled ✅ 15ms
Compression.Compressed Body Is Decompressible ✅ 14ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2130ms
Event Options.Cookieless Mode Override ✅ 7ms
Event Options.Disable Skew Correction Override ✅ 4ms
Event Options.Process Person Profile Override ✅ 5ms
Event Options.Product Tour Id Override ✅ 5ms
Event Options.Unset Options Omitted ✅ 4ms
Event Options.Options Override In Batch ✅ 7ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 5ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 4ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 5ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 15ms
Request Payload.Flags Request Uses V2 Query Param ✅ 15ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 14ms
Request Payload.Flags Request Omits Authorization Header ✅ 15ms
Request Payload.Token In Flags Body Matches Init ✅ 15ms
Request Payload.Groups Round Trip ✅ 15ms
Request Payload.Groups Default To Empty Object ✅ 15ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 14ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 15ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 15ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 15ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 27ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 15ms
Retry Behavior.Retries Flags On 502 ✅ 16ms
Retry Behavior.Retries Flags On 504 ✅ 16ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 16ms

Silthus and others added 2 commits November 4, 2026 07:14
- 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>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 2e2458c: no findings.

@Silthus
Silthus marked this pull request as ready for review November 4, 2026 08:59
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 08:59
Comment thread emails.go
return newEmailSend(body, e.IdempotencyKey, e.MaxRetries)
}

func checkAttachments(attachments []EmailAttachment) *EmailError {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the look. Leaving this one to the server, on purpose:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@veria-ai

veria-ai Bot commented Nov 4, 2026

Copy link
Copy Markdown

PR overview

The 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

@Silthus
Silthus changed the base branch from feat/emails-send to main November 5, 2026 12:09
#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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 20f7605: no findings.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant