From a44272b819d36227c0bf954dacd2c707c7e13f37 Mon Sep 17 00:00:00 2001 From: Daniel Selans Date: Tue, 10 Mar 2026 10:28:37 -0500 Subject: [PATCH] fix split to be html tag aware --- kustomer.go | 19 +++++++++--- kustomer_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/kustomer.go b/kustomer.go index f22080d..04a4e0c 100644 --- a/kustomer.go +++ b/kustomer.go @@ -710,21 +710,32 @@ func getResponseBody(resp *http.Response) ([]byte, error) { return buf.Bytes(), nil } -// chunkHTMLBody is used to split an HTML body into chunks of 1024 characters that -// are then passed to Kustomer as custom variables in the conversation. +// chunkHTMLBody splits an HTML body into chunks that are passed to Kustomer as +// custom variables in the conversation. Each chunk is at most htmlBodyChunkSize +// characters long, and splits are made at the last '>' boundary so that HTML +// tags are never broken across chunks. func chunkHTMLBody(input string) map[string]string { result := make(map[string]string) var counter int - for start := 0; start < len(input); start += htmlBodyChunkSize { + for start := 0; start < len(input); { end := start + htmlBodyChunkSize - if end > len(input) { + if end >= len(input) { end = len(input) + } else { + // Find the last '>' at or before the chunk boundary so we + // never split in the middle of an HTML tag. + lastClose := strings.LastIndex(input[start:end], ">") + if lastClose != -1 { + end = start + lastClose + 1 + } } key := fmt.Sprintf("htmlContent%dStr", counter) result[key] = input[start:end] counter++ + + start = end } return result diff --git a/kustomer_test.go b/kustomer_test.go index 33f5620..9b04352 100644 --- a/kustomer_test.go +++ b/kustomer_test.go @@ -3,6 +3,7 @@ package kustomersdk import ( "bytes" "context" + "fmt" "io" "net/http" "os" @@ -176,6 +177,84 @@ var _ = Describe("Kustomer SDK", func() { }) }) + Context("chunkHTMLBody", func() { + It("returns a single chunk when content is under 1024 chars", func() { + msg := "

Hello world

" + chunks := chunkHTMLBody(msg) + Expect(chunks).To(HaveLen(1)) + Expect(chunks["htmlContent0Str"]).To(Equal(msg)) + }) + + It("splits at the last '>' before the 1024-char boundary", func() { + // Build two paragraphs that together exceed 1024 chars. + // The split should land on a '>' boundary, never mid-tag. + first := "

" + strings.Repeat("a", 1005) + "

" // 1012 chars + second := "

" + strings.Repeat("b", 500) + "

" // 507 chars + msg := first + second + + chunks := chunkHTMLBody(msg) + + // Reassemble and verify nothing was lost + reassembled := "" + for i := 0; i < len(chunks); i++ { + key := fmt.Sprintf("htmlContent%dStr", i) + chunk := chunks[key] + reassembled += chunk + + // Every chunk must end with '>' or be the last chunk (plain text tail) + if i < len(chunks)-1 { + Expect(chunk[len(chunk)-1]).To(Equal(byte('>'))) + } + } + Expect(reassembled).To(Equal(msg)) + }) + + It("does not split in the middle of an HTML tag", func() { + // Place a tag so the naive 1024 split would land inside it + before := strings.Repeat("x", 1020) + tag := "" + after := "bold" + msg := before + tag + after + + chunks := chunkHTMLBody(msg) + + // The first chunk should NOT contain a partial tag + reassembled := "" + for i := 0; i < len(chunks); i++ { + key := fmt.Sprintf("htmlContent%dStr", i) + reassembled += chunks[key] + } + Expect(reassembled).To(Equal(msg)) + + // First chunk must end at or before the '<' of the tag + Expect(strings.HasSuffix(chunks["htmlContent0Str"], ""+strings.Repeat("a", 500)+"

", 10) + chunks := chunkHTMLBody(msg) + + reassembled := "" + for i := 0; i < len(chunks); i++ { + key := fmt.Sprintf("htmlContent%dStr", i) + reassembled += chunks[key] + } + Expect(reassembled).To(Equal(msg)) + }) + + It("handles content with no HTML tags by falling back to max chunk size", func() { + msg := strings.Repeat("a", 2500) + chunks := chunkHTMLBody(msg) + + reassembled := "" + for i := 0; i < len(chunks); i++ { + key := fmt.Sprintf("htmlContent%dStr", i) + reassembled += chunks[key] + } + Expect(reassembled).To(Equal(msg)) + }) + }) + Context("chunkSMSMessage", func() { It("returns a single element when the message is less than 1600 chars", func() { msg := "This is a short message"