Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emails-code-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-go": minor
---

A `CodeEmail` takes `Cc`, `Bcc` and `Attachments`. `Cc` and `Bcc` are lists of bare addresses, at most 10 each. An `EmailAttachment` is a `Filename` and its `Content` bytes, with an optional `ContentType` and `ContentID` (to show the file inline as `cid:<ContentID>`); the client sends the bytes Base64 encoded. PostHog takes at most 10 attachments and 512 KB in total, and refuses file types that mailbox providers block. An attachment without a `Filename` or `Content` fails with the code `invalid_attachment` before any request, and the error never holds the file's content. Empty lists are left out of the request. A PostHog version that doesn't take one of these fields yet refuses the send with an error that names the field, instead of sending without it.
10 changes: 10 additions & 0 deletions api/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,9 @@ type CodeEmail struct {
Text string
From string
ReplyTo []string
Cc []string
Bcc []string
Attachments []EmailAttachment
CaptureEngagement *bool
DistinctID string
IdempotencyKey string
Expand Down Expand Up @@ -452,6 +455,13 @@ type DescriptionExtractor interface {
type Email interface {
}

type EmailAttachment struct {
Filename string `json:"filename"`
Content []byte `json:"content"`
ContentType string `json:"content_type,omitempty"`
ContentID string `json:"content_id,omitempty"`
}

type EmailError struct {
Name EmailErrorName
Code string
Expand Down
81 changes: 69 additions & 12 deletions emails.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ type CodeEmail struct {
// Without it, PostHog sends from the project's default sender.
From string
ReplyTo []string
// Cc are bare addresses, such as "finance@example.com", that get a copy
// the other recipients see. At most 10.
Cc []string
// Bcc are bare addresses that get a copy the other recipients never see.
// At most 10.
Bcc []string
// Attachments are files sent with the email, at most 10 and 512 KB in
// total. PostHog refuses file types that mailbox providers block, such as
// .exe. PostHog can't fetch a file from a path or URL: read the file and
// pass its bytes.
Attachments []EmailAttachment
// CaptureEngagement says whether PostHog captures the email's engagement
// events, such as opens and clicks, as events of the recipient. Nil means
// true. A project whose admin turned engagement capture off captures
Expand All @@ -130,6 +141,21 @@ type CodeEmail struct {
MaxRetries *int
}

// EmailAttachment is a file sent with a CodeEmail.
type EmailAttachment struct {
// Filename is the name the recipient sees, such as "invoice.pdf", with no
// path.
Filename string `json:"filename"`
// Content is the file's bytes. The client sends them Base64 encoded.
Content []byte `json:"content"`
// ContentType is a MIME type, such as "application/pdf". Without it, the
// type follows from Filename.
ContentType string `json:"content_type,omitempty"`
// ContentID shows the file inline: the HTML references it as
// <img src="cid:logo"> when ContentID is "logo".
ContentID string `json:"content_id,omitempty"`
}

// SendEmailResult is an email that PostHog queued.
type SendEmailResult struct {
// ID is the queued email's message ID.
Expand Down Expand Up @@ -180,15 +206,18 @@ type templateEmailBody struct {
}

type codeEmailBody struct {
To string `json:"to"`
TemplateKey string `json:"template_key"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
From string `json:"from,omitempty"`
ReplyTo []string `json:"reply_to,omitempty"`
CaptureEngagement bool `json:"capture_engagement"`
DistinctID string `json:"distinct_id,omitempty"`
To string `json:"to"`
TemplateKey string `json:"template_key"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
From string `json:"from,omitempty"`
ReplyTo []string `json:"reply_to,omitempty"`
Cc []string `json:"cc,omitempty"`
Bcc []string `json:"bcc,omitempty"`
Attachments []EmailAttachment `json:"attachments,omitempty"`
CaptureEngagement bool `json:"capture_engagement"`
DistinctID string `json:"distinct_id,omitempty"`
}

func (e TemplateEmail) prepare() (emailSend, *EmailError) {
Expand Down Expand Up @@ -227,11 +256,23 @@ func (e CodeEmail) prepare() (emailSend, *EmailError) {
if e.HTML == "" && e.Text == "" {
return emailSend{}, invalidEmail("invalid_form", "HTML", "A CodeEmail needs HTML, Text or both.")
}
for _, address := range e.ReplyTo {
if strings.TrimSpace(address) == "" {
return emailSend{}, invalidEmail("invalid_reply_to", "ReplyTo", "ReplyTo must not hold a blank address.")
for _, list := range []struct {
field, code string
addresses []string
}{
{"ReplyTo", "invalid_reply_to", e.ReplyTo},
{"Cc", "invalid_cc", e.Cc},
{"Bcc", "invalid_bcc", e.Bcc},
} {
for _, address := range list.addresses {
if strings.TrimSpace(address) == "" {
return emailSend{}, invalidEmail(list.code, list.field, list.field+" must not hold a blank address.")
}
}
}
if err := checkAttachments(e.Attachments); err != nil {
return emailSend{}, err
}
body, err := encodeEmailBody(codeEmailBody{
To: e.To,
TemplateKey: e.TemplateKey,
Expand All @@ -240,6 +281,9 @@ func (e CodeEmail) prepare() (emailSend, *EmailError) {
Text: e.Text,
From: e.From,
ReplyTo: e.ReplyTo,
Cc: e.Cc,
Bcc: e.Bcc,
Attachments: e.Attachments,
CaptureEngagement: e.CaptureEngagement == nil || *e.CaptureEngagement,
DistinctID: e.DistinctID,
})
Expand All @@ -249,6 +293,19 @@ func (e CodeEmail) prepare() (emailSend, *EmailError) {
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.

for i, attachment := range attachments {
field := "Attachments[" + strconv.Itoa(i) + "]"
if strings.TrimSpace(attachment.Filename) == "" {
return invalidEmail("invalid_attachment", field+".Filename", "An attachment needs a Filename, such as \"invoice.pdf\".")
}
if len(attachment.Content) == 0 {
return invalidEmail("invalid_attachment", field+".Content", "An attachment needs Content, the file's bytes.")
}
}
return nil
}

func checkRecipient(to string) *EmailError {
if strings.TrimSpace(to) == "" {
return invalidEmail("invalid_recipient", "To", "To must be one email address.")
Expand Down
92 changes: 92 additions & 0 deletions emails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,91 @@ func TestEmailsSendCodeLeavesOutUnsetFields(t *testing.T) {
}, server.received()[0].Body)
}

func TestEmailsSendCodeCopiesAndAttachments(t *testing.T) {
server := newEmailServer(t, failed(http.StatusServiceUnavailable, "delivery_unavailable"), accepted("msg_1"))
client := newEmailClient(t, server)
invite := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
logo := []byte{0x89, 'P', 'N', 'G', 0xff, 0xfe}

_, err := client.Emails().Send(context.Background(), CodeEmail{
To: "user@example.com",
TemplateKey: "invoice",
Subject: "Your invoice",
HTML: `<img src="cid:logo">`,
Cc: []string{"manager@example.com"},
Bcc: []string{"audit@example.com", "archive@example.com"},
Attachments: []EmailAttachment{
{Filename: "invite.ics", Content: invite, ContentType: "text/calendar"},
{Filename: "logo.png", Content: logo, ContentID: "logo"},
},
})

require.NoError(t, err)
first, retry := server.received()[0], server.received()[1]
require.Equal(t, []interface{}{"manager@example.com"}, first.Body["cc"])
require.Equal(t, []interface{}{"audit@example.com", "archive@example.com"}, first.Body["bcc"])
require.Equal(t, []interface{}{
map[string]interface{}{"filename": "invite.ics", "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=", "content_type": "text/calendar"},
map[string]interface{}{"filename": "logo.png", "content": "iVBOR//+", "content_id": "logo"},
}, first.Body["attachments"], "content goes out as standard Base64")
require.Equal(t, first.Raw, retry.Raw, "a retry sends the same body")
}

func TestEmailsSendCodeLeavesOutEmptyCopiesAndAttachments(t *testing.T) {
server := newEmailServer(t, accepted("msg_1"))
client := newEmailClient(t, server)

_, err := client.Emails().Send(context.Background(), CodeEmail{
To: "user@example.com",
TemplateKey: "digest",
Subject: "Your digest",
Text: "Plain text only",
ReplyTo: []string{},
Cc: []string{},
Bcc: []string{},
Attachments: []EmailAttachment{},
})

require.NoError(t, err)
body := server.received()[0].Body
for _, field := range []string{"reply_to", "cc", "bcc", "attachments"} {
require.NotContains(t, body, field, "a server that doesn't take a field yet refuses it even when empty")
}
}

func TestEmailsSendNamesAFieldTheServerDoesNotTakeYet(t *testing.T) {
server := newEmailServer(t, stubReply{status: http.StatusBadRequest, body: `{
"type": "validation_error",
"code": "invalid_input",
"detail": "PostHog doesn't take this field, so it doesn't send the email without it.",
"attr": "attachments"
}`})
client := newEmailClient(t, server)

_, err := client.Emails().Send(context.Background(), CodeEmail{
To: "user@example.com", TemplateKey: "invoice", Subject: "Your invoice", HTML: "<p>Attached</p>",
Attachments: []EmailAttachment{{Filename: "invoice.pdf", Content: []byte("%PDF-1.7")}},
})

emailErr := requireEmailError(t, err)
require.Equal(t, "attachments", emailErr.Field)
require.Equal(t, EmailErrorValidation, emailErr.Name)
require.Len(t, server.received(), 1, "a refused field is not retried")
}

func TestEmailsAttachmentErrorNeverHoldsTheContent(t *testing.T) {
server := newEmailServer(t)
client := newEmailClient(t, server)

_, err := client.Emails().Send(context.Background(), CodeEmail{
To: "user@example.com", TemplateKey: "invoice", Subject: "Your invoice", HTML: "<p>Attached</p>",
Attachments: []EmailAttachment{{Content: []byte("secret-file-content")}},
})

require.Error(t, err)
require.NotContains(t, fmt.Sprintf("%+v %#v", err, requireEmailError(t, err)), "secret-file-content")
}

func TestEmailsSendHeaders(t *testing.T) {
server := newEmailServer(t, accepted("msg_1"))
client := newEmailClient(t, server, func(c *Config) {
Expand Down Expand Up @@ -356,6 +441,13 @@ func TestEmailsSendChecksArguments(t *testing.T) {
{name: "no subject", email: code(func(e *CodeEmail) { e.Subject = "" }), code: "invalid_form", field: "Subject"},
{name: "no html or text", email: code(func(e *CodeEmail) { e.HTML = "" }), code: "invalid_form", field: "HTML"},
{name: "blank reply-to", email: code(func(e *CodeEmail) { e.ReplyTo = []string{"a@example.com", " "} }), code: "invalid_reply_to", field: "ReplyTo"},
{name: "blank cc", email: code(func(e *CodeEmail) { e.Cc = []string{""} }), code: "invalid_cc", field: "Cc"},
{name: "blank bcc", email: code(func(e *CodeEmail) { e.Bcc = []string{"a@example.com", "\t"} }), code: "invalid_bcc", field: "Bcc"},
{name: "attachment without a filename", email: code(func(e *CodeEmail) { e.Attachments = []EmailAttachment{{Content: []byte("hi")}} }), code: "invalid_attachment", field: "Attachments[0].Filename"},
{name: "attachment with a blank filename", email: code(func(e *CodeEmail) { e.Attachments = []EmailAttachment{{Filename: " ", Content: []byte("hi")}} }), code: "invalid_attachment", field: "Attachments[0].Filename"},
{name: "attachment without content", email: code(func(e *CodeEmail) {
e.Attachments = []EmailAttachment{{Filename: "a.txt", Content: []byte("hi")}, {Filename: "b.txt", Content: []byte{}}}
}), code: "invalid_attachment", field: "Attachments[1].Content"},
{name: "code idempotency key", email: code(func(e *CodeEmail) { e.IdempotencyKey = "Γ©" }), code: "invalid_idempotency_key", field: "IdempotencyKey"},
{name: "code negative retries", email: code(func(e *CodeEmail) { e.MaxRetries = Ptr(-2) }), code: "invalid_max_retries", field: "MaxRetries"},
{name: "more than nine retries", email: template(func(e *TemplateEmail) { e.MaxRetries = Ptr(10) }), code: "invalid_max_retries", field: "MaxRetries"},
Expand Down
29 changes: 29 additions & 0 deletions example_emails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"os"

"github.com/posthog/posthog-go"
)
Expand Down Expand Up @@ -58,6 +59,34 @@ func ExampleEmails_code() {
}
}

// A receipt with a copy to finance and the PDF attached. The client sends the
// file's bytes Base64 encoded.
func ExampleEmails_attachments() {
client, err := posthog.NewWithConfig("phc_...", posthog.Config{SecretKey: "phs_..."})
if err != nil {
log.Fatal(err)
}
defer client.Close()

receiptPDF, err := os.ReadFile("receipt.pdf")
if err != nil {
log.Fatal(err)
}
_, err = client.Emails().Send(context.Background(), posthog.CodeEmail{
To: "user@example.com",
TemplateKey: "receipt",
Subject: "Your receipt",
HTML: "<p>Thanks for your order. Your receipt is attached.</p>",
Cc: []string{"finance@example.com"},
Attachments: []posthog.EmailAttachment{
{Filename: "receipt.pdf", Content: receiptPDF, ContentType: "application/pdf"},
},
})
if err != nil {
log.Fatal(err)
}
}

// Read what happened to a sent email, for 30 days after the send.
func ExampleEmails_Get() {
client, err := posthog.NewWithConfig("phc_...", posthog.Config{SecretKey: "phs_..."})
Expand Down
Loading