diff --git a/.changeset/emails-code-fields.md b/.changeset/emails-code-fields.md new file mode 100644 index 00000000..fcc7388a --- /dev/null +++ b/.changeset/emails-code-fields.md @@ -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:`). An email carries at most 10 attachments and at most 40 MB after Base64 encoding, counting the bodies (the HTML twice without `Text`, since PostHog derives the text from it) and the encoded subject as PostHog does, about 29 MB of files; `Send` checks both before any request and fails with `too_many_attachments` or `message_too_large`. It then hashes each file with SHA-256, asks PostHog which files it lacks, uploads each file PostHog lacks once, straight to PostHog's file storage without the secret key, over https unless `AllowInsecureAppHost` is set and never through a redirect, and sends the email naming each file by its hash. PostHog keeps uploaded files for its retention period, and a file it already has isn't uploaded again, and an upload URL that expired while earlier files uploaded is asked for again. A failed upload fails with `attachment_upload_failed` and the storage's answer, or with `network_error` when storage can't be reached, naming the attachment in `Field`, and no email is sent; a storage 5xx, 429 or `RequestTimeout` and a network error are retried first, honoring storage's `Retry-After` up to 30 seconds; a longer wait comes back in `EmailError.RetryAfter` for the caller to honor. A refusal of the upload names the attachments in `Field` as `Attachments` or `Attachments[i]`. The day's upload quota running out fails with `upload_quota_exceeded`, named `EmailErrorDailyQuotaExceeded`, and isn't retried. An attachment without a `Filename` or `Content` fails with `invalid_attachment` before any request, and no error holds a file's content or an upload URL. Empty lists are left out of the request. A PostHog version that doesn't take `Cc` or `Bcc` yet refuses the send with an error that names the field, instead of sending without it; one without the upload route, or a project that can't send email from code, answers `not_found` on `Attachments` before any upload, and nothing is sent. diff --git a/api/public-api.txt b/api/public-api.txt index f8a787b7..7dc897f1 100644 --- a/api/public-api.txt +++ b/api/public-api.txt @@ -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 @@ -452,6 +455,13 @@ type DescriptionExtractor interface { type Email interface { } +type EmailAttachment struct { + Filename string + Content []byte + ContentType string + ContentID string +} + type EmailError struct { Name EmailErrorName Code string @@ -481,6 +491,7 @@ const ( EmailErrorInvalidIdempotentRequest EmailErrorName = "invalid_idempotent_request" EmailErrorRateLimitExceeded EmailErrorName = "rate_limit_exceeded" EmailErrorMonthlyQuotaExceeded EmailErrorName = "monthly_quota_exceeded" + EmailErrorDailyQuotaExceeded EmailErrorName = "daily_quota_exceeded" EmailErrorServiceUnavailable EmailErrorName = "service_unavailable" EmailErrorApplication EmailErrorName = "application_error" ) diff --git a/config.go b/config.go index 464e641c..c4de268a 100644 --- a/config.go +++ b/config.go @@ -238,18 +238,23 @@ type Config struct { AppHost string // AllowInsecureAppHost lets Client.Emails send to an http AppHost, such as - // http://localhost:8010 during local development. Email requests carry - // SecretKey and the email itself, so over http both travel unencrypted. + // http://localhost:8010 during local development, and upload attachments + // to an http file storage URL. Email requests carry SecretKey and the + // email itself, so over http both travel unencrypted. AllowInsecureAppHost bool - // EmailRequestTimeout is the timeout of each Client.Emails request. If - // zero, it defaults to DefaultEmailRequestTimeout. + // EmailRequestTimeout is the timeout of each Client.Emails request to + // PostHog. If zero, it defaults to DefaultEmailRequestTimeout. Uploading + // an attachment to file storage isn't such a request: each upload may take + // up to 15 minutes, the life of its upload URL. Bound a whole Send with + // its context. EmailRequestTimeout time.Duration // EmailRequestMaxRetries is the number of retries of a Client.Emails - // request after its first attempt, 0 to 9. If nil, it defaults to - // DefaultEmailRequestMaxRetries. Set Ptr(0) when a job runner already - // retries the send, and pass an IdempotencyKey that names the email. + // request after its first attempt, and of each attachment upload, 0 to 9. + // If nil, it defaults to DefaultEmailRequestMaxRetries. Set Ptr(0) when a + // job runner already retries the send, and pass an IdempotencyKey that + // names the email. EmailRequestMaxRetries *int // MigratedFrom names the email provider your sends moved from, such as diff --git a/emails.go b/emails.go index 889c9e32..b4cf5833 100644 --- a/emails.go +++ b/emails.go @@ -95,8 +95,9 @@ type TemplateEmail struct { } // CodeEmail sends content your code rendered, named by a key. PostHog sends -// it as is and never stores it. To send a React email, render it to HTML in -// your app and pass the HTML. +// the subject and bodies as is and never stores them; it keeps attachments, +// as Attachments says. To send a React email, render it to HTML in your app +// and pass the HTML. type CodeEmail struct { // To is one recipient email address. To string @@ -113,6 +114,24 @@ 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 at most 40 MB + // for the whole message after Base64 encoding, which leaves about 29 MB + // for the files. Send checks both before any request. It then hashes each + // file, uploads the files PostHog doesn't have yet straight to PostHog's + // file storage, and sends the email naming them, so the network must + // allow uploads to the storage host the upload URL names. A file PostHog + // already has isn't uploaded again. 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. PostHog keeps each + // uploaded file in its file storage for its retention period, and a later + // send of the same bytes, from any of the project's code, reuses it. + 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 @@ -130,6 +149,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 + // Content is the file's bytes. + Content []byte + // ContentType is a MIME type, such as "application/pdf". Without it, the + // type follows from Filename. + ContentType string + // ContentID shows the file inline: the HTML references it as + // when ContentID is "logo". + ContentID string +} + // SendEmailResult is an email that PostHog queued. type SendEmailResult struct { // ID is the queued email's message ID. @@ -168,6 +202,7 @@ type EmailStatus struct { type emailSend struct { body []byte + attachments []attachment idempotencyKey string maxRetries *int } @@ -180,15 +215,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 []attachment `json:"attachments,omitempty"` + CaptureEngagement bool `json:"capture_engagement"` + DistinctID string `json:"distinct_id,omitempty"` } func (e TemplateEmail) prepare() (emailSend, *EmailError) { @@ -227,11 +265,24 @@ 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.") + } } } + attachments, attachmentsErr := checkAttachments(e.Attachments, e.Subject, e.HTML, e.Text) + if attachmentsErr != nil { + return emailSend{}, attachmentsErr + } body, err := encodeEmailBody(codeEmailBody{ To: e.To, TemplateKey: e.TemplateKey, @@ -240,13 +291,18 @@ func (e CodeEmail) prepare() (emailSend, *EmailError) { Text: e.Text, From: e.From, ReplyTo: e.ReplyTo, + Cc: e.Cc, + Bcc: e.Bcc, + Attachments: attachments, CaptureEngagement: e.CaptureEngagement == nil || *e.CaptureEngagement, DistinctID: e.DistinctID, }) if err != nil { return emailSend{}, newEmailError("unexpected_error", "The client could not encode the email.", err) } - return newEmailSend(body, e.IdempotencyKey, e.MaxRetries) + send, sendErr := newEmailSend(body, e.IdempotencyKey, e.MaxRetries) + send.attachments = attachments + return send, sendErr } func checkRecipient(to string) *EmailError { @@ -281,6 +337,10 @@ type emailSender struct { http http.Client logger Logger backoff func(attempt int) time.Duration + // now is the client's clock, which times how long an upload URL lives. + now func() time.Time + // allowInsecureUploads lets attachments upload to an http URL. + allowInsecureUploads bool // ctx ends when Close runs out of time, which aborts requests and retry waits. ctx context.Context @@ -304,8 +364,11 @@ func newEmailSender(config Config, transport http.RoundTripper) *emailSender { }, logger: config.Logger, backoff: NewBackoff(time.Second, 2, 0.2, maxEmailRetryDelay).Duration, - ctx: ctx, - cancel: cancel, + now: config.now, + + allowInsecureUploads: config.AllowInsecureAppHost, + ctx: ctx, + cancel: cancel, } } @@ -367,6 +430,11 @@ func (s *emailSender) Send(ctx context.Context, email Email) (*SendEmailResult, if send.maxRetries != nil { maxRetries = *send.maxRetries } + if len(send.attachments) > 0 { + if err := s.uploadAttachments(ctx, maxRetries, send.attachments); err != nil { + return nil, err + } + } header := s.header() header.Set("Content-Type", "application/json") header.Set("Idempotency-Key", send.idempotencyKey) @@ -465,27 +533,42 @@ func (s *emailSender) header() http.Header { // can fix. Every attempt carries the same header, so a send's idempotency key // holds across retries. A reply it returns is always a 2xx. func (s *emailSender) request(ctx context.Context, maxRetries int, method, url string, header http.Header, body []byte) (emailReply, *EmailError) { + var reply emailReply + err := s.retry(ctx, maxRetries, func(ctx context.Context) *EmailError { + var err *EmailError + reply, err = s.do(ctx, method, url, header, body) + if err == nil && !reply.accepted() { + err = reply.error() + } + return err + }) + if err != nil { + return emailReply{}, err + } + return reply, nil +} + +// retry runs attempt until it succeeds, retrying what waiting can fix. Close +// running out of time ends the attempt and the wait before the next one. +func (s *emailSender) retry(ctx context.Context, maxRetries int, attempt func(context.Context) *EmailError) *EmailError { ctx, cancel := context.WithCancel(ctx) defer cancel() defer context.AfterFunc(s.ctx, cancel)() for n := 0; ; n++ { - reply, err := s.do(ctx, method, url, header, body) - if err == nil && !reply.accepted() { - err = reply.error() - } + err := attempt(ctx) if err == nil { - return reply, nil + return nil } delay, retry := s.retryDelay(err, n, maxRetries) if !retry { - return emailReply{}, err + return err } timer := time.NewTimer(delay) select { case <-timer.C: case <-ctx.Done(): timer.Stop() - return emailReply{}, err.interruptedBy(ctx.Err()) + return err.interruptedBy(ctx.Err()) } } } diff --git a/emails_attachments.go b/emails_attachments.go new file mode 100644 index 00000000..bfc3cf0d --- /dev/null +++ b/emails_attachments.go @@ -0,0 +1,328 @@ +package posthog + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + emailUploadsPath = "/api/projects/@current/messaging/attachments/uploads/" + // maxEmailAttachments and maxEmailMessageBytes are PostHog's limits on one + // email: 10 files, and 40 MB for the message after Base64 encoding. + maxEmailAttachments = 10 + maxEmailMessageBytes = 40_000_000 + // emailHeadersAndMIMEBytes is what PostHog reserves for the message's + // headers and MIME boundaries when it estimates its size. It counts the + // subject apart, as encoded words of 75 bytes per 42 bytes of UTF-8. + emailHeadersAndMIMEBytes = 64 * 1024 + subjectBytesPerEncodedWord = 42 + encodedWordBytes = 75 + // emailUploadTimeout is how long an upload URL accepts the upload. + emailUploadTimeout = 15 * time.Minute + // uploadURLMargin is how long before its expiry an upload URL is asked for + // again, so a PUT never starts on a URL that expires on its way. + uploadURLMargin = time.Minute + maxBucketAnswerBytes = 64 << 10 +) + +// attachment is an EmailAttachment named by the SHA-256 of its content. A +// send carries it as a reference: its content goes only to file storage. +type attachment struct { + SHA256 string `json:"sha256"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + ContentID string `json:"content_id,omitempty"` + content []byte +} + +type uploadRequestFile struct { + SHA256 string `json:"sha256"` + Size int `json:"size"` +} + +// uploadTarget is where PostHog asked the client to upload one file. +type uploadTarget struct { + url string + method string + headers map[string]string + expiresAt time.Time +} + +// expired says whether the URL may no longer accept an upload that starts now. +func (t uploadTarget) expired(now time.Time) bool { + return !now.Before(t.expiresAt.Add(-uploadURLMargin)) +} + +func checkAttachments(attachments []EmailAttachment, subject, html, text string) ([]attachment, *EmailError) { + if len(attachments) > maxEmailAttachments { + return nil, invalidEmail("too_many_attachments", "Attachments", "An email carries at most 10 attachments.") + } + files := make([]attachment, 0, len(attachments)) + sizes := make([]int, 0, len(attachments)+2) + for i, file := range attachments { + field := "Attachments[" + strconv.Itoa(i) + "]" + if strings.TrimSpace(file.Filename) == "" { + return nil, invalidEmail("invalid_attachment", field+".Filename", "An attachment needs a Filename, such as \"invoice.pdf\".") + } + if len(file.Content) == 0 { + return nil, invalidEmail("invalid_attachment", field+".Content", "An attachment needs Content, the file's bytes.") + } + sum := sha256.Sum256(file.Content) + files = append(files, attachment{ + SHA256: hex.EncodeToString(sum[:]), + Filename: file.Filename, + ContentType: file.ContentType, + ContentID: file.ContentID, + content: file.Content, + }) + sizes = append(sizes, len(file.Content)) + } + if text == "" { + // PostHog derives the text from the HTML and counts it too. The + // derived text is never longer than the HTML. + text = html + } + sizes = append(sizes, len(html), len(text)) + if len(files) > 0 && estimateMessageBytes(sizes, len(subject)) > maxEmailMessageBytes { + return nil, invalidEmail("message_too_large", "Attachments", "The email would be over 40 MB after Base64 encoding, PostHog's limit. Send fewer or smaller files, about 29 MB in total, or link to large files from the body.") + } + return files, nil +} + +// estimateMessageBytes is PostHog's estimate of a message whose files and +// bodies have these sizes: each part Base64 encoded in 76-character lines, +// plus the encoded subject and room for headers and MIME boundaries. +func estimateMessageBytes(partSizes []int, subjectBytes int) int64 { + encodedWords := (int64(subjectBytes) + subjectBytesPerEncodedWord - 1) / subjectBytesPerEncodedWord + total := int64(emailHeadersAndMIMEBytes) + encodedWords*encodedWordBytes + for _, size := range partSizes { + encoded := (int64(size) + 2) / 3 * 4 + total += (encoded*78 + 75) / 76 + } + return total +} + +// uploadAttachments asks PostHog which files it lacks and uploads each one +// once, so a send can name them by their hashes. It returns at the first +// failure. +func (s *emailSender) uploadAttachments(ctx context.Context, maxRetries int, files []attachment) *EmailError { + targets, err := s.uploadTargets(ctx, maxRetries, files, 0) + if err != nil { + return err + } + uploaded := map[string]bool{} + for i, target := range targets { + file := files[i] + if target == nil || uploaded[file.SHA256] { + continue + } + if err := s.retry(ctx, maxRetries, func(ctx context.Context) *EmailError { + if target.expired(s.now()) { + fresh, err := s.uploadTargets(ctx, 0, []attachment{file}, i) + if err != nil { + return err + } + if target = fresh[0]; target == nil { + return nil + } + } + if err := s.upload(ctx, file, *target); err != nil { + err.Field = "Attachments[" + strconv.Itoa(i) + "]" + return err + } + return nil + }); err != nil { + return err + } + uploaded[file.SHA256] = true + } + return nil +} + +// uploadTargets asks PostHog where to upload each file: per file, in request +// order, nil when PostHog has the file and a target otherwise. The files are +// the attachments from index first on, so a refusal names the right one. +func (s *emailSender) uploadTargets(ctx context.Context, maxRetries int, files []attachment, first int) ([]*uploadTarget, *EmailError) { + requested := make([]uploadRequestFile, 0, len(files)) + for _, file := range files { + requested = append(requested, uploadRequestFile{SHA256: file.SHA256, Size: len(file.content)}) + } + body, err := encodeEmailBody(map[string]interface{}{"files": requested}) + if err != nil { + return nil, newEmailError("unexpected_error", "The client could not encode the upload request.", err) + } + header := s.header() + header.Set("Content-Type", "application/json") + reply, emailErr := s.request(ctx, maxRetries, http.MethodPost, s.appHost+emailUploadsPath, header, body) + if emailErr != nil { + emailErr.Field = attachmentsField(emailErr, first) + return nil, emailErr + } + targets, emailErr := reply.uploadTargets(files, s.now()) + if emailErr != nil { + return nil, emailErr + } + for i, target := range targets { + if target != nil && !s.allowInsecureUploads && !strings.HasPrefix(strings.ToLower(target.url), "https://") { + emailErr := reply.apiError("PostHog named an upload URL that isn't https, so " + files[i].Filename + " would travel unencrypted. Fix the file storage's URL, or set AllowInsecureAppHost for local development. No email was sent.") + emailErr.Field = "Attachments[" + strconv.Itoa(first+i) + "]" + return nil, emailErr + } + } + return targets, nil +} + +// attachmentsField names the field of an upload route refusal as the caller +// knows it: the caller never sent "files", only Attachments. A 404 comes from a +// PostHog without the upload route or a project that can't send email from +// code; the client can't tell which, so it keeps PostHog's answer and names +// the attachments. The request's file i is attachment first+i. +func attachmentsField(err *EmailError, first int) string { + switch { + case err.Field == "files" || (err.Field == "" && err.StatusCode == http.StatusNotFound): + return "Attachments" + case strings.HasPrefix(err.Field, "files__"): + index, _, _ := strings.Cut(strings.TrimPrefix(err.Field, "files__"), "__") + if i, convErr := strconv.Atoi(index); convErr == nil { + return "Attachments[" + strconv.Itoa(first+i) + "]" + } + return "Attachments" + } + return err.Field +} + +// uploadTargets reads PostHog's answer to the upload request. A URL expires +// at its url_expires_at or 15 minutes after the answer, whichever comes +// first, so a client clock behind PostHog's can't keep an expired URL. +func (r emailReply) uploadTargets(files []attachment, answeredAt time.Time) ([]*uploadTarget, *EmailError) { + slots, _ := r.fields["files"].([]interface{}) + if len(slots) != len(files) { + return nil, r.apiError("PostHog answered without an upload slot for each file.") + } + targets := make([]*uploadTarget, len(files)) + for i, item := range slots { + fields, _ := item.(map[string]interface{}) + slot := emailReply{fields: fields} + if slot.text("sha256") != files[i].SHA256 { + return nil, r.apiError("PostHog answered with upload slots for other files.") + } + switch slot.text("status") { + case "present": + case "upload": + target := &uploadTarget{url: slot.text("url"), method: slot.text("method"), headers: map[string]string{}} + if target.url == "" || target.method == "" { + return nil, r.apiError("PostHog answered without where to upload " + files[i].Filename + ".") + } + target.expiresAt = answeredAt.Add(emailUploadTimeout) + if expiresAt, err := time.Parse(time.RFC3339Nano, slot.text("url_expires_at")); err == nil && expiresAt.Before(target.expiresAt) { + target.expiresAt = expiresAt + } + headers, _ := fields["headers"].(map[string]interface{}) + for name, value := range headers { + target.headers[name], _ = value.(string) + } + targets[i] = target + default: + return nil, r.apiError("PostHog answered with an upload status the client doesn't know.") + } + } + return targets, nil +} + +// upload sends a file's bytes to file storage with exactly the headers PostHog +// signed, and never the secret key. +func (s *emailSender) upload(ctx context.Context, file attachment, target uploadTarget) *EmailError { + ctx, cancel := context.WithTimeout(ctx, emailUploadTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, target.method, target.url, bytes.NewReader(file.content)) + if err != nil { + return newEmailError("unexpected_error", "The client could not build the upload of "+file.Filename+".", withoutURL(err)) + } + for name, value := range target.headers { + if strings.EqualFold(name, "Host") { + req.Host = value + continue + } + req.Header.Set(name, value) + } + res, err := s.http.Do(req) + if err != nil { + return newEmailError("network_error", "Uploading "+file.Filename+" failed. No email was sent.", withoutURL(err)) + } + defer res.Body.Close() + retryAfter, _ := parseRetryAfter(res.Header.Get("Retry-After"), time.Now()) + answer, err := io.ReadAll(io.LimitReader(res.Body, maxBucketAnswerBytes)) + if err != nil { + cutOff := newEmailError("network_error", "Reading file storage's answer to the upload of "+file.Filename+" failed. No email was sent.", withoutURL(err)) + cutOff.StatusCode = res.StatusCode + cutOff.RetryAfter = retryAfter + return cutOff + } + if res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + refused := uploadRefused(file.Filename, res.StatusCode, answer) + refused.RetryAfter = retryAfter + return refused +} + +// uploadRefused is file storage's refusal of an upload, with its error code +// and message when it gave them. A 5xx, a 429 and a RequestTimeout (a stalled +// upload, which S3 answers with a 400) are retried, as S3 asks; any other +// refusal fails the send. +func uploadRefused(filename string, status int, answer []byte) *EmailError { + var refusal struct { + Code string `xml:"Code"` + Message string `xml:"Message"` + } + message := "Uploading " + filename + " failed: file storage answered HTTP " + strconv.Itoa(status) + if xml.Unmarshal(answer, &refusal) == nil && refusal.Code != "" { + message += ", " + refusal.Code + if refusal.Message != "" { + message += ": " + strings.TrimRight(refusal.Message, ".") + } + } + err := newEmailError("attachment_upload_failed", message+". No email was sent.", nil) + err.StatusCode = status + switch { + case status == http.StatusTooManyRequests: + err.Name = EmailErrorRateLimitExceeded + case status >= http.StatusInternalServerError || refusal.Code == "RequestTimeout": + err.Name = EmailErrorServiceUnavailable + } + return err +} + +// withoutURL drops every URL from a request error. An upload URL is signed, +// and so is a storage redirect, which Go quotes when it can't parse the +// redirect's Location, so neither may reach logs. An error whose text names no +// URL is kept as it is, so a caller can still tell a timeout or a refused +// connection. +func withoutURL(err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + if text := err.Error(); !strings.Contains(text, "://") && !strings.Contains(text, "?") { + return err + } + const redacted = "the request failed with an error naming a URL, left out because it may be signed" + for _, kind := range []error{context.Canceled, context.DeadlineExceeded} { + if errors.Is(err, kind) { + return fmt.Errorf(redacted+": %w", kind) + } + } + return errors.New(redacted) +} diff --git a/emails_attachments_test.go b/emails_attachments_test.go new file mode 100644 index 00000000..6809af76 --- /dev/null +++ b/emails_attachments_test.go @@ -0,0 +1,566 @@ +package posthog + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// testClock is a clock the test moves forward by hand. +type testClock struct { + mu sync.Mutex + at time.Time +} + +func (c *testClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.at +} + +func (c *testClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.at = c.at.Add(d) +} + +// attachmentsWorld is PostHog and its file storage, with a client that sends +// email through them. +type attachmentsWorld struct { + storage *fakeStorage + posthog *fakePostHog + client Client +} + +func newAttachmentsWorld(t *testing.T, faults ...storageFault) attachmentsWorld { + storage := newFakeStorage(t, time.Now, faults...) + posthog := newFakePostHog(t, storage) + return attachmentsWorld{storage: storage, posthog: posthog, client: newEmailClientFor(t, posthog.URL)} +} + +func invoiceWith(attachments ...EmailAttachment) CodeEmail { + return CodeEmail{ + To: "user@example.com", + TemplateKey: "invoice", + Subject: "Your invoice", + HTML: `

Attached

`, + Attachments: attachments, + } +} + +func withoutRetries(email CodeEmail) CodeEmail { + email.MaxRetries = Ptr(0) + return email +} + +func storageRefusal(status int, code string) storageFault { + return storageFault{stubReply: stubReply{status: status, body: ` +` + code + `Storage said no.`}} +} + +func TestEmailsSendDeliversAttachments(t *testing.T) { + invoice := []byte("%PDF-1.7 invoice") + logo := []byte{0x89, 'P', 'N', 'G', 0xff, 0xfe} + world := newAttachmentsWorld(t) + world.storage.put(logo) + + result, err := world.client.Emails().Send(context.Background(), invoiceWith( + EmailAttachment{Filename: "invoice.pdf", Content: invoice, ContentType: "application/pdf"}, + EmailAttachment{Filename: "logo.png", Content: logo, ContentID: "logo"}, + )) + + require.NoError(t, err) + require.Equal(t, "msg_1", result.ID) + require.Equal(t, []deliveredEmail{{ + To: "user@example.com", Subject: "Your invoice", + Attachments: []deliveredAttachment{ + {Filename: "invoice.pdf", ContentType: "application/pdf", Content: invoice}, + {Filename: "logo.png", ContentID: "logo", Content: logo}, + }, + }}, world.posthog.deliveredEmails()) + require.Equal(t, invoice, world.storage.stored(sha256Hex(invoice))) +} + +func TestEmailsSendDeliversFilesPostHogHasWithoutStorage(t *testing.T) { + terms := []byte("%PDF-1.7 terms") + world := newAttachmentsWorld(t) + world.storage.put(terms) + world.storage.Close() + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "terms.pdf", Content: terms})) + + require.NoError(t, err) + require.Equal(t, terms, world.posthog.deliveredEmails()[0].Attachments[0].Content) +} + +func TestEmailsSendDeliversAFileAttachedTwice(t *testing.T) { + // Storage throttles a second upload of the same bytes. The file is + // already there, so the email still goes out. + logo := []byte{0x89, 'P', 'N', 'G'} + world := newAttachmentsWorld(t, storageFault{}, storageRefusal(http.StatusTooManyRequests, "SlowDown")) + + _, err := world.client.Emails().Send(context.Background(), withoutRetries(invoiceWith( + EmailAttachment{Filename: "logo.png", Content: logo, ContentID: "logo"}, + EmailAttachment{Filename: "logo-copy.png", Content: logo}, + ))) + + require.NoError(t, err) + require.Equal(t, []deliveredAttachment{ + {Filename: "logo.png", ContentID: "logo", Content: logo}, + {Filename: "logo-copy.png", Content: logo}, + }, world.posthog.deliveredEmails()[0].Attachments) +} + +func TestEmailsSendStopsWhenAnUploadFails(t *testing.T) { + // The fault is used once, so a retry would upload the file and send. + invoice := []byte("%PDF-1.7 invoice") + world := newAttachmentsWorld(t, storageFault{stubReply: stubReply{status: http.StatusBadRequest, body: ` +BadDigestThe SHA256 you specified did not match the calculated checksum.`}}) + + _, err := world.client.Emails().Send(context.Background(), invoiceWith( + EmailAttachment{Filename: "logo.png", Content: []byte("png")}, + EmailAttachment{Filename: "invoice.pdf", Content: invoice}, + )) + + emailErr := requireEmailError(t, err) + require.Equal(t, "attachment_upload_failed", emailErr.Code) + require.Equal(t, EmailErrorApplication, emailErr.Name) + require.Equal(t, http.StatusBadRequest, emailErr.StatusCode) + require.Equal(t, "Attachments[0]", emailErr.Field) + require.Equal(t, "Uploading logo.png failed: file storage answered HTTP 400, BadDigest: The SHA256 you specified did not match the calculated checksum. No email was sent.", emailErr.Message) + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendStopsWhenStorageIsUnreachable(t *testing.T) { + invoice := []byte("%PDF-1.7 invoice") + world := newAttachmentsWorld(t) + world.storage.Close() + + _, err := world.client.Emails().Send(context.Background(), withoutRetries(invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: invoice}))) + + emailErr := requireEmailError(t, err) + require.Equal(t, "network_error", emailErr.Code) + require.Equal(t, EmailErrorServiceUnavailable, emailErr.Name) + require.Equal(t, "Attachments[0]", emailErr.Field) + require.Equal(t, "Uploading invoice.pdf failed. No email was sent.", emailErr.Message) + for cause := error(emailErr); cause != nil; cause = errors.Unwrap(cause) { + require.NotContains(t, fmt.Sprintf("%v %+v", cause, cause), "X-Amz-Signature") + } + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendUploadErrorNeverHoldsARedirectsSignedURL(t *testing.T) { + // Go reads a redirect's Location before any redirect policy runs, and + // quotes a Location it can't parse in its error. Storage redirects keep + // the request's signature, so that text must not reach the caller. + for name, location := range map[string]string{ + "absolute": "http://storage.example/team/2/%zz?X-Amz-Signature=secret", + "relative": "/team/2/%zz?X-Amz-Signature=secret", + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t, storageFault{stubReply: stubReply{status: http.StatusTemporaryRedirect, header: map[string]string{"Location": location}}}) + + _, err := world.client.Emails().Send(context.Background(), withoutRetries(invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")}))) + + emailErr := requireEmailError(t, err) + require.Equal(t, "network_error", emailErr.Code) + for cause := error(emailErr); cause != nil; cause = errors.Unwrap(cause) { + require.NotContains(t, fmt.Sprintf("%v %+v", cause, cause), "X-Amz-Signature") + } + require.Empty(t, world.posthog.deliveredEmails()) + }) + } +} + +func TestEmailsSendNeverFollowsAStorageRedirect(t *testing.T) { + // Following a 307 would re-send the file to wherever storage points, even + // over http, and report success. A signed upload never works through a + // redirect, so a redirect fails the attachment. + var elsewhere int + var mu sync.Mutex + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + elsewhere++ + mu.Unlock() + })) + t.Cleanup(other.Close) + world := newAttachmentsWorld(t, storageFault{stubReply: stubReply{status: http.StatusTemporaryRedirect, header: map[string]string{"Location": other.URL + "/team/2/invoice"}}}) + + _, err := world.client.Emails().Send(context.Background(), withoutRetries(invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")}))) + + emailErr := requireEmailError(t, err) + require.Equal(t, "attachment_upload_failed", emailErr.Code) + require.Equal(t, http.StatusTemporaryRedirect, emailErr.StatusCode) + require.Equal(t, "Attachments[0]", emailErr.Field) + require.Equal(t, "Uploading invoice.pdf failed: file storage answered HTTP 307. No email was sent.", emailErr.Message) + for cause := error(emailErr); cause != nil; cause = errors.Unwrap(cause) { + require.NotContains(t, fmt.Sprintf("%v %+v", cause, cause), other.URL) + } + mu.Lock() + require.Zero(t, elsewhere, "the file went to the redirect's target") + mu.Unlock() + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendStopsWhenPostHogRefusesTheUploads(t *testing.T) { + world := newAttachmentsWorld(t) + world.posthog.uploadFaults = map[int]stubReply{0: {status: http.StatusBadRequest, body: `{ + "type": "invalid_request", + "code": "field_not_accepted", + "detail": "This project can't send files yet, so PostHog didn't prepare the uploads.", + "attr": "files", + "why": "Sending files isn't available to this project yet.", + "fix": "Send the email without files, or try again once files are available to this project." + }`}} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, "field_not_accepted", emailErr.Code) + require.Equal(t, EmailErrorValidation, emailErr.Name) + require.Equal(t, "Sending files isn't available to this project yet.", emailErr.Why) + require.Equal(t, "Attachments", emailErr.Field, "the caller never sent files, so the field is named as they know it") + require.Empty(t, world.posthog.deliveredEmails()) + require.True(t, world.storage.isEmpty()) +} + +func TestEmailsSendReportsAMissingUploadRouteAsPostHogAnswered(t *testing.T) { + // A PostHog without the upload route, or a project that can't send email + // from code, answers 404. The client can't tell which, so it reports + // PostHog's answer on the attachments instead of guessing a cause. + world := newAttachmentsWorld(t) + world.posthog.withoutUploadRoute = true + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, "not_found", emailErr.Code) + require.Equal(t, EmailErrorNotFound, emailErr.Name) + require.Equal(t, http.StatusNotFound, emailErr.StatusCode) + require.Equal(t, "Attachments", emailErr.Field) + require.Equal(t, "Endpoint not found.", emailErr.Message) + require.Empty(t, world.posthog.deliveredEmails()) + require.True(t, world.storage.isEmpty()) +} + +func TestEmailsSendNamesTheAttachmentPostHogRefusedToTake(t *testing.T) { + world := newAttachmentsWorld(t) + world.posthog.uploadFaults = map[int]stubReply{0: {status: http.StatusBadRequest, body: `{"type":"invalid_request","code":"message_too_large","detail":"Too large.","attr":"files__1__size"}`}} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith( + EmailAttachment{Filename: "logo.png", Content: []byte("png")}, + EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")}, + )) + + emailErr := requireEmailError(t, err) + require.Equal(t, "message_too_large", emailErr.Code) + require.Equal(t, "Attachments[1]", emailErr.Field) + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendStopsOnAnUploadAnswerItCannotUse(t *testing.T) { + invoice := []byte("%PDF-1.7 invoice") + for name, slot := range map[string]string{ + "another file's hash": `{"sha256":"` + sha256Hex([]byte("another file")) + `","status":"present","expires_at":"2026-12-16T10:00:00Z"}`, + "an upload without url": `{"sha256":"` + sha256Hex(invoice) + `","status":"upload","method":"PUT"}`, + "an unknown status": `{"sha256":"` + sha256Hex(invoice) + `","status":"scanning"}`, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t) + world.posthog.uploadFaults = map[int]stubReply{0: {status: http.StatusOK, body: `{"files":[` + slot + `]}`}} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: invoice})) + + require.Equal(t, "api_error", requireEmailError(t, err).Code) + require.Empty(t, world.posthog.deliveredEmails()) + require.True(t, world.storage.isEmpty()) + }) + } +} + +func TestEmailsSendCountsTheBodiesInTheMessageSize(t *testing.T) { + scan := make([]byte, 29_100_000) + for name, tt := range map[string]struct { + html string + fits bool + }{ + "beside a short body it fits": {html: "

Your scan

", fits: true}, + "beside a 100 KB body it's too large": {html: "

" + strings.Repeat("x", 100_000) + "

"}, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t) + + _, err := world.client.Emails().Send(context.Background(), CodeEmail{ + To: "user@example.com", TemplateKey: "scan", Subject: "Your scan", HTML: tt.html, + Attachments: []EmailAttachment{{Filename: "scan.pdf", Content: scan}}, + }) + + requireFitsTheMessageSize(t, world, err, tt.fits) + }) + } +} + +func TestEmailsSendCountsTheSubjectInTheMessageSize(t *testing.T) { + // PostHog counts a subject as 75 bytes per 42 bytes of UTF-8, the encoded + // words it folds into. With a one-byte text body, this file leaves room + // for one encoded word, not two. + scan := make([]byte, 29_182_800) + for name, tt := range map[string]struct { + subject string + fits bool + }{ + "a 7-byte subject fits": {subject: "Receipt", fits: true}, + "a 50-byte subject is too much": {subject: "Ihre Bestellbestätigung Nr. 12345 vom 9. November"}, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t) + + _, err := world.client.Emails().Send(context.Background(), CodeEmail{ + To: "user@example.com", TemplateKey: "scan", Subject: tt.subject, Text: "a", + Attachments: []EmailAttachment{{Filename: "scan.pdf", Content: scan}}, + }) + + requireFitsTheMessageSize(t, world, err, tt.fits) + }) + } +} + +func TestEmailsSendCountsTheTextPostHogDerivesFromTheHTML(t *testing.T) { + // Without Text, PostHog derives the plain text from the HTML and counts it + // too. The derived text is never longer than the HTML, so the client + // counts the HTML twice: this file and HTML fit beside a one-byte text, + // and not alone. + scan := make([]byte, 29_082_000) + html := "

" + strings.Repeat("x", 99_993) + "

" + for name, tt := range map[string]struct { + text string + fits bool + }{ + "with its own short text it fits": {text: "a", fits: true}, + "without text it's too large": {}, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t) + + _, err := world.client.Emails().Send(context.Background(), CodeEmail{ + To: "user@example.com", TemplateKey: "scan", Subject: "Your scan", HTML: html, Text: tt.text, + Attachments: []EmailAttachment{{Filename: "scan.pdf", Content: scan}}, + }) + + requireFitsTheMessageSize(t, world, err, tt.fits) + }) + } +} + +func requireFitsTheMessageSize(t *testing.T, world attachmentsWorld, err error, fits bool) { + t.Helper() + if fits { + require.NoError(t, err) + require.Len(t, world.posthog.deliveredEmails(), 1) + return + } + emailErr := requireEmailError(t, err) + require.Equal(t, "message_too_large", emailErr.Code) + require.Equal(t, "Attachments", emailErr.Field) + require.True(t, world.storage.isEmpty(), "nothing is uploaded for an email PostHog would refuse") + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendUploadsOverHTTPOnlyWhenAllowed(t *testing.T) { + // PostHog over HTTPS names a plain-HTTP storage URL, as misconfigured + // self-hosted storage would. The file and its signed URL would travel + // unencrypted, so the client stops unless AllowInsecureAppHost allows + // plain HTTP, as it does for local development. + storage := newFakeStorage(t, time.Now) + posthog := newFakePostHogOverTLS(t, storage) + client := newEmailClientFor(t, posthog.URL, func(c *Config) { + c.AllowInsecureAppHost = false + c.Transport = posthog.Client().Transport + }) + + _, err := client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, "api_error", emailErr.Code) + require.Equal(t, "Attachments[0]", emailErr.Field) + require.True(t, storage.isEmpty()) + require.Empty(t, posthog.deliveredEmails()) +} + +func TestEmailsSendRetriesWhatFileStorageAsksToRetry(t *testing.T) { + for name, fault := range map[string]storageFault{ + "500": storageRefusal(http.StatusInternalServerError, "InternalError"), + "502": {stubReply: stubReply{status: http.StatusBadGateway}}, + "503": storageRefusal(http.StatusServiceUnavailable, "SlowDown"), + "504": {stubReply: stubReply{status: http.StatusGatewayTimeout}}, + "429": {stubReply: stubReply{status: http.StatusTooManyRequests}}, + "RequestTimeout": storageRefusal(http.StatusBadRequest, "RequestTimeout"), + "an answer broke off": {stubReply: stubReply{status: http.StatusOK}, breakOff: true}, + } { + t.Run(name, func(t *testing.T) { + invoice := []byte("%PDF-1.7 invoice") + world := newAttachmentsWorld(t, fault) + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: invoice})) + + require.NoError(t, err) + require.Equal(t, invoice, world.storage.stored(sha256Hex(invoice))) + require.Equal(t, invoice, world.posthog.deliveredEmails()[0].Attachments[0].Content) + }) + } +} + +func TestEmailsSendNamesAStorageRefusalByWhatItAsks(t *testing.T) { + for name, tt := range map[string]struct { + refusal storageFault + want EmailErrorName + }{ + "throttled": {storageRefusal(http.StatusTooManyRequests, "SlowDown"), EmailErrorRateLimitExceeded}, + "timed out": {storageRefusal(http.StatusBadRequest, "RequestTimeout"), EmailErrorServiceUnavailable}, + "unavailable": {storageRefusal(http.StatusServiceUnavailable, "SlowDown"), EmailErrorServiceUnavailable}, + "wrong digest": {storageRefusal(http.StatusBadRequest, "BadDigest"), EmailErrorApplication}, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t, tt.refusal) + + _, err := world.client.Emails().Send(context.Background(), withoutRetries(invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")}))) + + emailErr := requireEmailError(t, err) + require.Equal(t, "attachment_upload_failed", emailErr.Code) + require.Equal(t, tt.want, emailErr.Name) + require.Empty(t, world.posthog.deliveredEmails()) + }) + } +} + +func TestEmailsSendHandsBackAWaitLongerThanItRetries(t *testing.T) { + // Storage asks for two minutes, longer than the client waits, so the + // caller gets the wait instead of a blocked send. + for name, fault := range map[string]storageFault{ + "a refusal": {stubReply: stubReply{status: http.StatusServiceUnavailable, header: map[string]string{"Retry-After": "120"}, body: `SlowDownPlease reduce your request rate.`}}, + "an answer that broke off": {stubReply: stubReply{status: http.StatusServiceUnavailable, header: map[string]string{"Retry-After": "120"}}, breakOff: true}, + } { + t.Run(name, func(t *testing.T) { + world := newAttachmentsWorld(t, fault) + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, 2*time.Minute, emailErr.RetryAfter) + require.Empty(t, world.posthog.deliveredEmails()) + }) + } +} + +func TestEmailsSendDoesNotRetryTheDailyUploadQuota(t *testing.T) { + // The quota answers once, so a retry would upload the file and send. + world := newAttachmentsWorld(t) + world.posthog.uploadFaults = map[int]stubReply{0: dailyUploadQuotaExceeded()} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, "upload_quota_exceeded", emailErr.Code) + require.Equal(t, EmailErrorDailyQuotaExceeded, emailErr.Name) + require.Empty(t, world.posthog.deliveredEmails()) +} + +func dailyUploadQuotaExceeded() stubReply { + return stubReply{status: http.StatusTooManyRequests, body: `{"type":"throttled_error","code":"upload_quota_exceeded","detail":"This project uploaded as many file bytes as it can today."}`} +} + +func TestEmailsSendAsksAgainForAnUploadURLThatExpired(t *testing.T) { + // PostHog's first answer names a URL that has already expired. + invoice := []byte("%PDF-1.7 invoice") + world := newAttachmentsWorld(t) + world.posthog.urlLifetimes = []time.Duration{-time.Second} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: invoice})) + + require.NoError(t, err) + require.Equal(t, invoice, world.storage.stored(sha256Hex(invoice))) + require.Len(t, world.posthog.deliveredEmails(), 1) +} + +func TestEmailsSendTimesAnUploadURLByItsOwnClock(t *testing.T) { + // The client's clock runs an hour behind PostHog's and storage's, so + // PostHog's deadline reads an hour later than it is. The first upload + // takes 16 minutes, past the second URL's life, and the client must + // notice by timing the URL itself. + invoice, logo := []byte("%PDF-1.7 invoice"), []byte("logo") + clock := &testClock{at: time.Now()} + storage := newFakeStorage(t, clock.now) + posthog := newFakePostHog(t, storage) + firstUpload := sync.Once{} + storage.afterStore = func() { firstUpload.Do(func() { clock.advance(16 * time.Minute) }) } + client := newEmailClientFor(t, posthog.URL, func(c *Config) { + c.now = func() time.Time { return clock.now().Add(-time.Hour) } + }) + + _, err := client.Emails().Send(context.Background(), invoiceWith( + EmailAttachment{Filename: "invoice.pdf", Content: invoice}, + EmailAttachment{Filename: "logo.png", Content: logo}, + )) + + require.NoError(t, err) + require.Equal(t, logo, storage.stored(sha256Hex(logo))) + require.Len(t, posthog.deliveredEmails(), 1) +} + +func TestEmailsSendSkipsAnExpiredUploadPostHogNowHas(t *testing.T) { + // The URL expired, and by the time the client asks again another send + // uploaded the same file, so storage is no longer needed. + invoice := []byte("%PDF-1.7 invoice") + world := newAttachmentsWorld(t) + world.posthog.urlLifetimes = []time.Duration{-time.Second} + world.posthog.beforeUploadAnswer = func(n int) { + if n == 1 { + world.storage.put(invoice) + world.storage.Close() + } + } + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: invoice})) + + require.NoError(t, err) + require.Equal(t, invoice, world.posthog.deliveredEmails()[0].Attachments[0].Content) +} + +func TestEmailsSendNamesTheAttachmentWhoseURLPostHogWouldNotRenew(t *testing.T) { + world := newAttachmentsWorld(t) + world.posthog.urlLifetimes = []time.Duration{-time.Second} + world.storage.put([]byte("png")) + world.posthog.uploadFaults = map[int]stubReply{1: {status: http.StatusBadRequest, body: `{"type":"invalid_request","code":"message_too_large","detail":"Too large.","attr":"files__0__size"}`}} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith( + EmailAttachment{Filename: "logo.png", Content: []byte("png")}, + EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF-1.7 invoice")}, + )) + + emailErr := requireEmailError(t, err) + require.Equal(t, "message_too_large", emailErr.Code) + require.Equal(t, "Attachments[1]", emailErr.Field, "the second ask lists one file, the second attachment") + require.Empty(t, world.posthog.deliveredEmails()) +} + +func TestEmailsSendKeepsPostHogsFieldWhenAskingAgainFails(t *testing.T) { + world := newAttachmentsWorld(t) + world.posthog.urlLifetimes = []time.Duration{-time.Second} + world.posthog.uploadFaults = map[int]stubReply{1: dailyUploadQuotaExceeded()} + + _, err := world.client.Emails().Send(context.Background(), invoiceWith(EmailAttachment{Filename: "invoice.pdf", Content: []byte("%PDF")})) + + emailErr := requireEmailError(t, err) + require.Equal(t, "upload_quota_exceeded", emailErr.Code) + require.Equal(t, EmailErrorDailyQuotaExceeded, emailErr.Name) + require.Empty(t, emailErr.Field) + require.Empty(t, world.posthog.deliveredEmails()) +} diff --git a/emails_error.go b/emails_error.go index 38aa09b3..d5d534e6 100644 --- a/emails_error.go +++ b/emails_error.go @@ -24,7 +24,8 @@ const ( // EmailErrorInvalidPermission: the secret key lacks the scope. EmailErrorInvalidPermission EmailErrorName = "invalid_permission" // EmailErrorNotFound: no template or email with this ID, or the API is not - // enabled for the project. + // enabled for the project. On Attachments it can also mean this PostHog + // can't take attachments yet; the client can't tell which. EmailErrorNotFound EmailErrorName = "not_found" // EmailErrorConcurrentIdempotentRequests: a send with the same idempotency // key is still in flight. @@ -32,11 +33,17 @@ const ( // EmailErrorInvalidIdempotentRequest: the idempotency key was used with // another request. EmailErrorInvalidIdempotentRequest EmailErrorName = "invalid_idempotent_request" - // EmailErrorRateLimitExceeded: PostHog rate limited the request. + // EmailErrorRateLimitExceeded: PostHog, or file storage for an upload, + // rate limited the request. EmailErrorRateLimitExceeded EmailErrorName = "rate_limit_exceeded" // EmailErrorMonthlyQuotaExceeded: the project is over its email quota. EmailErrorMonthlyQuotaExceeded EmailErrorName = "monthly_quota_exceeded" - // EmailErrorServiceUnavailable: PostHog or the network was unavailable. + // EmailErrorDailyQuotaExceeded: the project uploaded as many attachment + // bytes as it can today, counted in UTC. Files PostHog already has don't + // count, and emails without new attachments still go out. + EmailErrorDailyQuotaExceeded EmailErrorName = "daily_quota_exceeded" + // EmailErrorServiceUnavailable: PostHog, file storage for an upload, or + // the network was unavailable. EmailErrorServiceUnavailable EmailErrorName = "service_unavailable" // EmailErrorApplication: anything else. EmailErrorApplication EmailErrorName = "application_error" @@ -53,7 +60,8 @@ type EmailError struct { Name EmailErrorName Code string Message string - // StatusCode is the HTTP status when PostHog answered, and 0 otherwise. + // StatusCode is the HTTP status when PostHog, or file storage for an + // upload, answered, and 0 otherwise. // A network_error keeps the status of an answer that broke off mid-body. StatusCode int Why string @@ -61,8 +69,8 @@ type EmailError struct { DocsURL string // Field names the request field the error is about, when there is one. Field string - // RetryAfter is how long PostHog asked the client to wait before it sends - // again, and 0 when it did not ask. + // RetryAfter is how long PostHog, or file storage for an upload, asked the + // client to wait before it tries again, and 0 when it did not ask. RetryAfter time.Duration cause error @@ -90,8 +98,10 @@ func newEmailError(code, message string, cause error) *EmailError { return &EmailError{Name: emailErrorName(0, code), Code: code, Message: message, cause: cause} } +// invalidEmail is an argument the client refuses before any request. func invalidEmail(code, field, message string) *EmailError { err := newEmailError(code, message, nil) + err.Name = EmailErrorValidation err.Field = field return err } @@ -129,6 +139,9 @@ func emailErrorName(status int, code string) EmailErrorName { if code == "quota_limited" { return EmailErrorMonthlyQuotaExceeded } + if code == "upload_quota_exceeded" { + return EmailErrorDailyQuotaExceeded + } return EmailErrorRateLimitExceeded case http.StatusServiceUnavailable: return EmailErrorServiceUnavailable @@ -145,13 +158,18 @@ func (e *EmailError) isRetryable() bool { if e.Code == "network_error" { return true } + if e.Code == "attachment_upload_failed" { + // File storage throttled, timed out or was down; anything else it + // refused fails the same way again. + return e.Name == EmailErrorRateLimitExceeded || e.Name == EmailErrorServiceUnavailable + } switch e.StatusCode { case 0: return false case http.StatusConflict: return e.Code == "idempotency_key_in_flight" case http.StatusTooManyRequests: - return e.Code != "quota_limited" + return e.Code != "quota_limited" && e.Code != "upload_quota_exceeded" default: return e.StatusCode == http.StatusServiceUnavailable } diff --git a/emails_fakes_test.go b/emails_fakes_test.go new file mode 100644 index 00000000..436d45f6 --- /dev/null +++ b/emails_fakes_test.go @@ -0,0 +1,417 @@ +package posthog + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + json "github.com/goccy/go-json" +) + +// fakeStorage behaves like S3 behind presigned PUT URLs. It stores an object +// only when the PUT carries what PostHog signed, arrives before the URL +// expires, carries no other credentials, and its bytes match the signed +// SHA-256. Otherwise it answers as S3 does. +type fakeStorage struct { + *httptest.Server + clock func() time.Time + + mu sync.Mutex + objects map[string][]byte + // faults are answers to the next PUTs, for failures real storage can't + // be made to produce on demand. Each is used once, in order; one without + // a status lets that PUT through. + faults []storageFault + // afterStore, when set, runs each time storage has stored an upload. + afterStore func() +} + +type storageFault struct { + stubReply + // breakOff ends the answer after its status line and part of its body. + breakOff bool +} + +var fakeStorageSecret = []byte("fake-storage-signing-key") + +func newFakeStorage(t *testing.T, clock func() time.Time, faults ...storageFault) *fakeStorage { + s := &fakeStorage{clock: clock, objects: map[string][]byte{}, faults: faults} + s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(s.Close) + return s +} + +func (s *fakeStorage) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + var fault *storageFault + if len(s.faults) > 0 { + fault, s.faults = &s.faults[0], s.faults[1:] + } + s.mu.Unlock() + if fault != nil && fault.status != 0 { + for name, value := range fault.header { + w.Header().Set(name, value) + } + if fault.breakOff { + w.Header().Set("Content-Length", "100") + w.WriteHeader(fault.status) + _, _ = w.Write([]byte("")) + w.(http.Flusher).Flush() + panic(http.ErrAbortHandler) + } + w.WriteHeader(fault.status) + _, _ = w.Write([]byte(fault.body)) + return + } + + key := strings.TrimPrefix(r.URL.Path, "/") + expiresAt, _ := strconv.ParseInt(r.URL.Query().Get("X-Amz-Expires-At"), 10, 64) + checksum := r.Header.Get("X-Amz-Checksum-Sha256") + switch { + case r.Method != http.MethodPut: + s.refuse(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "The specified method is not allowed against this resource.") + case r.Header.Get("Authorization") != "": + s.refuse(w, http.StatusBadRequest, "InvalidArgument", "Only one auth mechanism allowed.") + case r.ContentLength < 0: + s.refuse(w, http.StatusLengthRequired, "MissingContentLength", "You must provide the Content-Length HTTP header.") + case !hmac.Equal([]byte(r.URL.Query().Get("X-Amz-Signature")), []byte(s.sign(key, expiresAt, r.ContentLength, checksum))): + s.refuse(w, http.StatusForbidden, "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided.") + case !s.clock().Before(time.Unix(expiresAt, 0)): + s.refuse(w, http.StatusForbidden, "AccessDenied", "Request has expired") + case checksum != checksumOf(body): + s.refuse(w, http.StatusBadRequest, "BadDigest", "The SHA256 you specified did not match the calculated checksum.") + default: + s.mu.Lock() + s.objects[key] = body + afterStore := s.afterStore + s.mu.Unlock() + if afterStore != nil { + afterStore() + } + w.WriteHeader(http.StatusOK) + } +} + +func (s *fakeStorage) refuse(w http.ResponseWriter, status int, code, message string) { + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, ` +%s%s`, code, message) +} + +func (s *fakeStorage) sign(key string, expiresAt, size int64, checksum string) string { + mac := hmac.New(sha256.New, fakeStorageSecret) + _, _ = fmt.Fprintf(mac, "PUT\n%s\n%d\n%d\n%s", key, expiresAt, size, checksum) + return hex.EncodeToString(mac.Sum(nil)) +} + +// slot signs an upload of the file with this SHA-256 and size, as PostHog's +// upload route does, for a URL that expires at expiresAt. +func (s *fakeStorage) slot(sha string, size int, expiresAt time.Time) uploadSlot { + key := objectKey(sha) + raw, _ := hex.DecodeString(sha) + checksum := base64.StdEncoding.EncodeToString(raw) + signature := s.sign(key, expiresAt.Unix(), int64(size), checksum) + return uploadSlot{ + "sha256": sha, + "status": "upload", + "url": fmt.Sprintf("%s/%s?X-Amz-Expires-At=%d&X-Amz-Signature=%s", s.URL, key, expiresAt.Unix(), signature), + "method": http.MethodPut, + "headers": map[string]string{ + "Content-Length": strconv.Itoa(size), + "x-amz-checksum-sha256": checksum, + }, + "url_expires_at": expiresAt.UTC().Format(time.RFC3339), + } +} + +// stored is what storage holds for the file with this SHA-256, or nil. +func (s *fakeStorage) stored(sha string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + return s.objects[objectKey(sha)] +} + +func (s *fakeStorage) put(content []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.objects[objectKey(sha256Hex(content))] = content +} + +func (s *fakeStorage) isEmpty() bool { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.objects) == 0 +} + +func objectKey(sha string) string { return "attachments/team/2/" + sha } + +func checksumOf(content []byte) string { + sum := sha256.Sum256(content) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +func sha256Hex(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +type uploadSlot map[string]interface{} + +// codeSendFields are the fields PostHog's code send takes. +var codeSendFields = []string{"to", "template_key", "subject", "html", "text", "from", "reply_to", "cc", "bcc", "attachments", "capture_engagement", "distinct_id"} + +// fieldsBeforeAttachments are the fields a PostHog from before cc, bcc and +// attachments takes. +var fieldsBeforeAttachments = []string{"to", "template_key", "subject", "html", "text", "from", "reply_to", "capture_engagement", "distinct_id"} + +var attachmentFields = map[string]bool{"sha256": true, "filename": true, "content_type": true, "content_id": true} + +var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// fakePostHog behaves like PostHog's upload and code send routes. The upload +// route answers present for a file storage holds and signs an upload for any +// other. The send refuses a field it doesn't take and a file storage doesn't +// hold, and delivers the email otherwise. +type fakePostHog struct { + *httptest.Server + storage *fakeStorage + clock func() time.Time + + mu sync.Mutex + // takes are the send fields this PostHog takes. + takes map[string]bool + // withoutUploadRoute makes it a PostHog from before the upload route. + withoutUploadRoute bool + // uploadFaults and sendFaults answer the upload or send request with + // this ordinal, from 0, in place of PostHog's own answer. + uploadFaults map[int]stubReply + sendFaults map[int]stubReply + // urlLifetimes is how long the upload URLs of each upload answer live, + // in order; 15 minutes past the list. + urlLifetimes []time.Duration + // beforeUploadAnswer runs before the upload route answers request n. + beforeUploadAnswer func(n int) + uploads, sends int + delivered []deliveredEmail +} + +type deliveredEmail struct { + To, Subject string + Cc, Bcc []string + Attachments []deliveredAttachment +} + +type deliveredAttachment struct { + Filename, ContentType, ContentID string + Content []byte +} + +func newFakePostHog(t *testing.T, storage *fakeStorage) *fakePostHog { + p := unstartedFakePostHog(storage) + p.Start() + t.Cleanup(p.Close) + return p +} + +// newFakePostHogOverTLS is PostHog served over HTTPS; its Client trusts it. +func newFakePostHogOverTLS(t *testing.T, storage *fakeStorage) *fakePostHog { + p := unstartedFakePostHog(storage) + p.StartTLS() + t.Cleanup(p.Close) + return p +} + +func unstartedFakePostHog(storage *fakeStorage) *fakePostHog { + p := &fakePostHog{storage: storage, clock: storage.clock, takes: map[string]bool{}} + for _, field := range codeSendFields { + p.takes[field] = true + } + p.Server = httptest.NewUnstartedServer(http.HandlerFunc(p.serve)) + return p +} + +// takeOnly makes this a PostHog that takes only these send fields. +func (p *fakePostHog) takeOnly(fields []string) { + p.mu.Lock() + defer p.mu.Unlock() + p.takes = map[string]bool{} + for _, field := range fields { + p.takes[field] = true + } +} + +func (p *fakePostHog) serve(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { + answer(w, http.StatusBadRequest, map[string]interface{}{"type": "validation_error", "code": "parse_error", "detail": "JSON parse error."}) + return + } + switch { + case r.Method != http.MethodPost: + answer(w, http.StatusMethodNotAllowed, map[string]interface{}{"type": "invalid_request", "code": "method_not_allowed"}) + case r.URL.Path == emailUploadsPath && !p.withoutUploadRoute: + p.authorized(w, r, func() { p.answerUploads(w, body) }) + case r.URL.Path == emailSendPath: + p.authorized(w, r, func() { p.send(w, body) }) + default: + answer(w, http.StatusNotFound, map[string]interface{}{"type": "invalid_request", "code": "not_found", "detail": "Endpoint not found.", "attr": nil}) + } +} + +func (p *fakePostHog) authorized(w http.ResponseWriter, r *http.Request, next func()) { + if r.Header.Get("Authorization") != "Bearer phs_test" { + answer(w, http.StatusUnauthorized, map[string]interface{}{"type": "authentication_error", "code": "authentication_failed", "detail": "Use a project secret key."}) + return + } + next() +} + +func (p *fakePostHog) answerUploads(w http.ResponseWriter, body map[string]interface{}) { + p.mu.Lock() + n := p.uploads + p.uploads++ + before := p.beforeUploadAnswer + fault, faulted := p.uploadFaults[n] + lifetime := 15 * time.Minute + if n < len(p.urlLifetimes) { + lifetime = p.urlLifetimes[n] + } + p.mu.Unlock() + if before != nil { + before(n) + } + if faulted { + reply(w, fault) + return + } + files, attr := uploadRequestFiles(body) + if attr != "" { + answer(w, http.StatusBadRequest, map[string]interface{}{"type": "validation_error", "code": "invalid_input", "detail": "Invalid upload request.", "attr": attr}) + return + } + slots := make([]uploadSlot, 0, len(files)) + for _, file := range files { + if p.storage.stored(file.SHA256) != nil { + slots = append(slots, uploadSlot{"sha256": file.SHA256, "status": "present", "expires_at": p.clock().Add(37 * 24 * time.Hour).UTC().Format(time.RFC3339)}) + continue + } + slots = append(slots, p.storage.slot(file.SHA256, file.Size, p.clock().Add(lifetime))) + } + answer(w, http.StatusOK, map[string]interface{}{"files": slots}) +} + +// uploadRequestFiles reads an upload request as PostHog does, refusing any +// key it doesn't take, and names the first field it refuses. +func uploadRequestFiles(body map[string]interface{}) ([]uploadRequestFile, string) { + for key := range body { + if key != "files" { + return nil, key + } + } + items, _ := body["files"].([]interface{}) + if len(items) < 1 || len(items) > 10 { + return nil, "files" + } + files := make([]uploadRequestFile, 0, len(items)) + for i, item := range items { + fields, _ := item.(map[string]interface{}) + sha, _ := fields["sha256"].(string) + size, _ := fields["size"].(float64) + if len(fields) != 2 || !sha256Pattern.MatchString(sha) || size < 1 || size != float64(int(size)) { + return nil, fmt.Sprintf("files__%d", i) + } + files = append(files, uploadRequestFile{SHA256: sha, Size: int(size)}) + } + return files, "" +} + +func (p *fakePostHog) send(w http.ResponseWriter, body map[string]interface{}) { + p.mu.Lock() + n := p.sends + p.sends++ + fault, faulted := p.sendFaults[n] + takes := p.takes + p.mu.Unlock() + if faulted { + reply(w, fault) + return + } + for key := range body { + if !takes[key] { + answer(w, http.StatusBadRequest, map[string]interface{}{"type": "validation_error", "code": "invalid_input", "detail": "PostHog doesn't take this field, so it doesn't send the email without it.", "attr": key}) + return + } + } + email := deliveredEmail{To: text(body, "to"), Subject: text(body, "subject"), Cc: texts(body, "cc"), Bcc: texts(body, "bcc")} + items, _ := body["attachments"].([]interface{}) + for i, item := range items { + fields, _ := item.(map[string]interface{}) + for key := range fields { + if !attachmentFields[key] { + answer(w, http.StatusBadRequest, map[string]interface{}{"type": "invalid_request", "code": "invalid_request", "detail": "An attachment takes `sha256`, `filename`, `content_type` and `content_id`.", "attr": fmt.Sprintf("attachments__%d__%s", i, key)}) + return + } + } + content := p.storage.stored(text(fields, "sha256")) + if content == nil { + answer(w, http.StatusBadRequest, map[string]interface{}{"type": "invalid_request", "code": "attachment_not_uploaded", "detail": "PostHog doesn't have this file.", "attr": fmt.Sprintf("attachments__%d__sha256", i)}) + return + } + email.Attachments = append(email.Attachments, deliveredAttachment{ + Filename: text(fields, "filename"), ContentType: text(fields, "content_type"), ContentID: text(fields, "content_id"), Content: content, + }) + } + p.mu.Lock() + p.delivered = append(p.delivered, email) + p.mu.Unlock() + answer(w, http.StatusAccepted, map[string]interface{}{"message_id": fmt.Sprintf("msg_%d", n+1), "replayed": false, "warnings": []string{}}) +} + +// deliveredEmails are the emails PostHog accepted to send, in order. +func (p *fakePostHog) deliveredEmails() []deliveredEmail { + p.mu.Lock() + defer p.mu.Unlock() + return append([]deliveredEmail(nil), p.delivered...) +} + +func answer(w http.ResponseWriter, status int, body interface{}) { + raw, _ := json.Marshal(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(raw) +} + +func reply(w http.ResponseWriter, r stubReply) { + for name, value := range r.header { + w.Header().Set(name, value) + } + w.WriteHeader(r.status) + _, _ = w.Write([]byte(r.body)) +} + +func text(fields map[string]interface{}, key string) string { + value, _ := fields[key].(string) + return value +} + +func texts(fields map[string]interface{}, key string) []string { + items, _ := fields[key].([]interface{}) + var values []string + for _, item := range items { + value, _ := item.(string) + values = append(values, value) + } + return values +} diff --git a/emails_test.go b/emails_test.go index 68dd0907..45f4102f 100644 --- a/emails_test.go +++ b/emails_test.go @@ -90,7 +90,11 @@ func failed(status int, code string) stubReply { type emailClientOption func(*Config) func newEmailClient(t *testing.T, server *emailServer, options ...emailClientOption) Client { - config := Config{Endpoint: server.URL, AppHost: server.URL, AllowInsecureAppHost: true, SecretKey: "phs_test", Logger: testLogger{t.Logf, t.Logf}} + return newEmailClientFor(t, server.URL, options...) +} + +func newEmailClientFor(t *testing.T, url string, options ...emailClientOption) Client { + config := Config{Endpoint: url, AppHost: url, AllowInsecureAppHost: true, SecretKey: "phs_test", Logger: testLogger{t.Logf, t.Logf}} for _, option := range options { option(&config) } @@ -205,6 +209,80 @@ func TestEmailsSendCodeLeavesOutUnsetFields(t *testing.T) { }, server.received()[0].Body) } +func TestEmailsSendCodeCopies(t *testing.T) { + // PostHog is briefly unavailable, so the copies must survive the retry. + posthog := newFakePostHog(t, newFakeStorage(t, time.Now)) + posthog.sendFaults = map[int]stubReply{0: failed(http.StatusServiceUnavailable, "delivery_unavailable")} + client := newEmailClientFor(t, posthog.URL) + + _, err := client.Emails().Send(context.Background(), CodeEmail{ + To: "user@example.com", + TemplateKey: "invoice", + Subject: "Your invoice", + HTML: "

Your invoice

", + Cc: []string{"manager@example.com"}, + Bcc: []string{"audit@example.com", "archive@example.com"}, + }) + + require.NoError(t, err) + require.Equal(t, []deliveredEmail{{ + To: "user@example.com", Subject: "Your invoice", + Cc: []string{"manager@example.com"}, + Bcc: []string{"audit@example.com", "archive@example.com"}, + }}, posthog.deliveredEmails()) +} + +func TestEmailsSendCodeWithEmptyCopiesReachesAnOlderPostHog(t *testing.T) { + // A PostHog from before cc, bcc and attachments refuses those fields even + // when empty, so empty lists must not stop the email. + posthog := newFakePostHog(t, newFakeStorage(t, time.Now)) + posthog.takeOnly(fieldsBeforeAttachments) + client := newEmailClientFor(t, posthog.URL) + + _, 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) + require.Len(t, posthog.deliveredEmails(), 1) +} + +func TestEmailsSendNamesAFieldTheServerDoesNotTakeYet(t *testing.T) { + posthog := newFakePostHog(t, newFakeStorage(t, time.Now)) + posthog.takeOnly(fieldsBeforeAttachments) + client := newEmailClientFor(t, posthog.URL) + + _, err := client.Emails().Send(context.Background(), CodeEmail{ + To: "user@example.com", TemplateKey: "invoice", Subject: "Your invoice", HTML: "

Copied

", + Cc: []string{"manager@example.com"}, + }) + + emailErr := requireEmailError(t, err) + require.Equal(t, "cc", emailErr.Field) + require.Equal(t, EmailErrorValidation, emailErr.Name) + require.Empty(t, posthog.deliveredEmails(), "the email isn't sent without the copy") +} + +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: "

Attached

", + 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) { @@ -356,6 +434,26 @@ 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: "more than 10 attachments", email: code(func(e *CodeEmail) { + for i := 0; i < 11; i++ { + e.Attachments = append(e.Attachments, EmailAttachment{Filename: fmt.Sprintf("%d.txt", i), Content: []byte{byte(i)}}) + } + }), code: "too_many_attachments", field: "Attachments"}, + {name: "a 30 MB file", email: code(func(e *CodeEmail) { + e.Attachments = []EmailAttachment{{Filename: "scan.pdf", Content: make([]byte, 30_000_000)}} + }), code: "message_too_large", field: "Attachments"}, + {name: "three 10 MB files", email: code(func(e *CodeEmail) { + for i := 0; i < 3; i++ { + e.Attachments = append(e.Attachments, EmailAttachment{Filename: fmt.Sprintf("%d.pdf", i), Content: make([]byte, 10_000_000)}) + } + }), code: "message_too_large", field: "Attachments"}, {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"}, diff --git a/example_emails_test.go b/example_emails_test.go index cebd23e2..53857d6e 100644 --- a/example_emails_test.go +++ b/example_emails_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "os" "github.com/posthog/posthog-go" ) @@ -58,6 +59,35 @@ func ExampleEmails_code() { } } +// A receipt with a copy to finance and the PDF attached. Send uploads the PDF +// to PostHog's file storage unless PostHog already has it, then sends the +// email. +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: "

Thanks for your order. Your receipt is attached.

", + 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_..."})