// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package mail import ( "encoding/base64" "encoding/json" "strings" "testing" ) // --------------------------------------------------------------------------- // parseLocalImgs // --------------------------------------------------------------------------- // TestParseLocalImgs_OrderAndDedup verifies parse local imgs order and dedup. func TestParseLocalImgs_OrderAndDedup(t *testing.T) { html := `
hi


`
got := parseLocalImgs(html)
if len(got) != 3 {
t.Fatalf("expected 3 imgs (duplicates preserved), got %d: %#v", len(got), got)
}
if got[0].Path != "a.png" || got[1].Path != "b.png" || got[2].Path != "a.png" {
t.Fatalf("unexpected order: %#v", got)
}
}
// TestParseLocalImgs_SkipRemoteAndSchemes verifies parse local imgs skip remote and schemes.
func TestParseLocalImgs_SkipRemoteAndSchemes(t *testing.T) {
html := `

`
got := parseLocalImgs(html)
if len(got) != 1 || got[0].Path != "local.png" {
t.Fatalf("expected only local.png, got %#v", got)
}
}
// TestParseLocalImgs_EmptySrcDropped verifies parse local imgs empty src dropped.
func TestParseLocalImgs_EmptySrcDropped(t *testing.T) {
html := `
`
got := replaceImgSrcOnce(html, "a.png", "cid:1")
want := `
`
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
// TestReplaceImgSrcOnce_NoMatch verifies replace img src once no match.
func TestReplaceImgSrcOnce_NoMatch(t *testing.T) {
html := `
`
got := replaceImgSrcOnce(html, "missing.png", "cid:x")
if got != html {
t.Fatalf("expected unchanged, got %q", got)
}
}
// ---------------------------------------------------------------------------
// templateMailboxPath / validateTemplateID
// ---------------------------------------------------------------------------
// TestTemplateMailboxPath verifies template mailbox path.
func TestTemplateMailboxPath(t *testing.T) {
cases := []struct {
mbox string
segments []string
want string
}{
{"u1", nil, "/open-apis/mail/v1/user_mailboxes/u1/templates"},
{"u1", []string{"42"}, "/open-apis/mail/v1/user_mailboxes/u1/templates/42"},
{"u 1", []string{"42", "", "attachments"}, "/open-apis/mail/v1/user_mailboxes/u%201/templates/42/attachments"},
}
for _, c := range cases {
if got := templateMailboxPath(c.mbox, c.segments...); got != c.want {
t.Errorf("templateMailboxPath(%q, %v) = %q; want %q", c.mbox, c.segments, got, c.want)
}
}
}
// TestValidateTemplateID verifies validate template id.
func TestValidateTemplateID(t *testing.T) {
if err := validateTemplateID(""); err != nil {
t.Errorf("empty id should be allowed, got %v", err)
}
if err := validateTemplateID("12345"); err != nil {
t.Errorf("decimal id should be allowed, got %v", err)
}
if err := validateTemplateID("abc"); err == nil {
t.Errorf("non-decimal id should be rejected")
}
if err := validateTemplateID("-1"); err != nil {
t.Errorf("negative decimal should parse, got %v", err)
}
}
// ---------------------------------------------------------------------------
// renderTemplateAddresses / joinTemplateAddresses / appendAddrList
// ---------------------------------------------------------------------------
// TestRenderAndJoinAddresses verifies render and join addresses.
func TestRenderAndJoinAddresses(t *testing.T) {
addrs := renderTemplateAddresses("Alice , b@x")
if len(addrs) != 2 {
t.Fatalf("expected 2 addrs, got %#v", addrs)
}
if addrs[0].Name != "Alice" || addrs[0].Address != "a@x" {
t.Fatalf("unexpected addr[0]: %#v", addrs[0])
}
if addrs[1].Name != "" || addrs[1].Address != "b@x" {
t.Fatalf("unexpected addr[1]: %#v", addrs[1])
}
joined := joinTemplateAddresses(addrs)
if !strings.Contains(joined, "a@x") || !strings.Contains(joined, "b@x") {
t.Fatalf("joined missing addresses: %q", joined)
}
if got := renderTemplateAddresses(""); got != nil {
t.Errorf("empty input should return nil, got %#v", got)
}
if got := joinTemplateAddresses(nil); got != "" {
t.Errorf("nil input should return empty, got %q", got)
}
// Skip entries with empty Address.
mix := []templateMailAddr{{Address: ""}, {Address: "x@x"}}
if got := joinTemplateAddresses(mix); got != "x@x" {
t.Errorf("expected 'x@x', got %q", got)
}
}
// TestAppendAddrList verifies append addr list.
func TestAppendAddrList(t *testing.T) {
if got := appendAddrList("", "b@x"); got != "b@x" {
t.Errorf("empty base, got %q", got)
}
if got := appendAddrList("a@x", ""); got != "a@x" {
t.Errorf("empty extra, got %q", got)
}
if got := appendAddrList("a@x", "b@x"); got != "a@x, b@x" {
t.Errorf("concat, got %q", got)
}
}
// ---------------------------------------------------------------------------
// generateTemplateCID / b64StdEncode
// ---------------------------------------------------------------------------
// TestGenerateTemplateCID verifies generate template c i d.
func TestGenerateTemplateCID(t *testing.T) {
a, err := generateTemplateCID()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := generateTemplateCID()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a == b {
t.Errorf("expected unique cids, got duplicate %q", a)
}
if len(a) < 32 {
t.Errorf("cid too short: %q", a)
}
}
// TestB64StdEncode verifies b64 std encode.
func TestB64StdEncode(t *testing.T) {
got := b64StdEncode([]byte("hello"))
want := base64.StdEncoding.EncodeToString([]byte("hello"))
if got != want {
t.Errorf("b64StdEncode = %q; want %q", got, want)
}
}
// ---------------------------------------------------------------------------
// sortStrings / countAddresses / countAttachmentsByType
// ---------------------------------------------------------------------------
// TestSortStrings verifies sort strings.
func TestSortStrings(t *testing.T) {
s := []string{"c", "a", "b", "a"}
sortStrings(s)
want := []string{"a", "a", "b", "c"}
for i := range s {
if s[i] != want[i] {
t.Fatalf("sortStrings = %v; want %v", s, want)
}
}
}
// TestCountAddresses verifies count addresses.
func TestCountAddresses(t *testing.T) {
if got := countAddresses(""); got != 0 {
t.Errorf("empty -> 0, got %d", got)
}
if got := countAddresses("a@x, b@x"); got != 2 {
t.Errorf("two -> 2, got %d", got)
}
}
// TestCountAttachmentsByType verifies count attachments by type.
func TestCountAttachmentsByType(t *testing.T) {
atts := []templateAttachment{
{IsInline: true, AttachmentType: attachmentTypeSmall},
{IsInline: false, AttachmentType: attachmentTypeSmall},
{IsInline: false, AttachmentType: attachmentTypeLarge},
{IsInline: true, AttachmentType: attachmentTypeSmall},
}
inlineCnt, largeCnt := countAttachmentsByType(atts)
if inlineCnt != 2 {
t.Errorf("inlineCnt = %d; want 2", inlineCnt)
}
if largeCnt != 1 {
t.Errorf("largeCnt = %d; want 1", largeCnt)
}
}
// ---------------------------------------------------------------------------
// templateAttachmentBuilder
// ---------------------------------------------------------------------------
// TestTemplateAttachmentBuilder_Small verifies small attachments stay SMALL and in projectedSize.
func TestTemplateAttachmentBuilder_Small(t *testing.T) {
b := newTemplateAttachmentBuilder("n", "s", "c", nil, nil, nil)
b.append("k1", "a.png", "cid1", true, 1024)
b.append("k2", "b.bin", "", false, 2048)
if err := b.finalize(); err != nil {
t.Fatalf("finalize: %v", err)
}
if len(b.attachments) != 2 {
t.Fatalf("attachments: %#v", b.attachments)
}
if b.attachments[0].AttachmentType != attachmentTypeSmall {
t.Errorf("inline should be SMALL, got %d", b.attachments[0].AttachmentType)
}
if b.attachments[1].AttachmentType != attachmentTypeSmall {
t.Errorf("non-inline small should be SMALL, got %d", b.attachments[1].AttachmentType)
}
if b.attachments[0].Body != "k1" {
t.Errorf("body should mirror ID, got %q", b.attachments[0].Body)
}
}
// TestTemplateAttachmentBuilder_LargeSwitch verifies non-inline flips to LARGE once projection exceeds threshold.
func TestTemplateAttachmentBuilder_LargeSwitch(t *testing.T) {
b := newTemplateAttachmentBuilder("n", "s", strings.Repeat("x", 1024), nil, nil, nil)
// One big non-inline pushes the cumulative projection past 25 MB.
big := int64(30 * 1024 * 1024)
b.append("k1", "huge.bin", "", false, big)
if err := b.finalize(); err != nil {
t.Fatalf("finalize: %v", err)
}
if got := b.attachments[0].AttachmentType; got != attachmentTypeLarge {
t.Errorf("expected LARGE for big file, got %d", got)
}
// Once bucket flips, subsequent non-inline also LARGE.
b.append("k2", "small.bin", "", false, 1024)
if got := b.attachments[1].AttachmentType; got != attachmentTypeLarge {
t.Errorf("sticky LARGE bucket should apply, got %d", got)
}
}
// TestTemplateAttachmentBuilder_InlineOverflowSurfaces verifies inline-only overflow is surfaced at finalize.
func TestTemplateAttachmentBuilder_InlineOverflowSurfaces(t *testing.T) {
b := newTemplateAttachmentBuilder("n", "s", "", nil, nil, nil)
// Inline images cannot flip to LARGE; their raw bytes count toward the 25 MB cap.
b.append("k1", "big.png", "cid1", true, 30*1024*1024)
if err := b.finalize(); err == nil {
t.Errorf("expected finalize error for inline overflow")
}
}
// ---------------------------------------------------------------------------
// wrapTemplateContentIfNeeded
// ---------------------------------------------------------------------------
// TestWrapTemplateContentIfNeeded verifies the wrap behavior. Plain-text
// templates also get HTML-wrapped here so the preview keeps line breaks; the
// is_plain_text_mode flag is honored on the apply/send side via a HTML→text
// strip pass in mergeTemplateBody.
func TestWrapTemplateContentIfNeeded(t *testing.T) {
if got := wrapTemplateContentIfNeeded("", false); got != "" {
t.Errorf("empty pass-through, got %q", got)
}
if got := wrapTemplateContentIfNeeded("x
", false); got != "x
" { t.Errorf("already-HTML should pass through, got %q", got) } // Plain text body in HTML mode → transformed. got := wrapTemplateContentIfNeeded("line1\nline2", false) if got == "line1\nline2" || !strings.Contains(got, "line1") || !strings.Contains(got, "tpl body
", Tos: []templateMailAddr{{Address: "t@x"}}, Ccs: []templateMailAddr{{Address: "c@x"}}, Bccs: []templateMailAddr{{Address: "b@x"}}, } merged := applyTemplate( templateShortcutSend, tpl, "", "", "", // no draft-derived "", "", // no draft subject/body "user-to@x", "user-cc@x", "user-bcc@x", "", "", // user flags ) if !strings.Contains(merged.To, "user-to@x") || !strings.Contains(merged.To, "t@x") { t.Errorf("To missing entries: %q", merged.To) } if !strings.Contains(merged.Cc, "user-cc@x") || !strings.Contains(merged.Cc, "c@x") { t.Errorf("Cc missing entries: %q", merged.Cc) } if !strings.Contains(merged.Bcc, "user-bcc@x") || !strings.Contains(merged.Bcc, "b@x") { t.Errorf("Bcc missing entries: %q", merged.Bcc) } if merged.Subject != "tpl-subject" { t.Errorf("Subject fallback should be template, got %q", merged.Subject) } if merged.Body != "tpl body
" { t.Errorf("empty draft body should use template, got %q", merged.Body) } } // TestApplyTemplate_UserSubjectWins verifies that an explicit user subject // takes precedence over both draft and template subjects. func TestApplyTemplate_UserSubjectWins(t *testing.T) { tpl := &templatePayload{Subject: "tpl"} merged := applyTemplate( templateShortcutSend, tpl, "", "", "", "draft-subj", "", "", "", "", "user-subj", "", ) if merged.Subject != "user-subj" { t.Errorf("user subject should win, got %q", merged.Subject) } } // TestApplyTemplate_DraftSubjectWinsOverTemplate verifies draft > template // when no user subject. func TestApplyTemplate_DraftSubjectWinsOverTemplate(t *testing.T) { tpl := &templatePayload{Subject: "tpl"} merged := applyTemplate( templateShortcutSend, tpl, "", "", "", "draft-subj", "", "", "", "", "", "", ) if merged.Subject != "draft-subj" { t.Errorf("draft subject should win over template, got %q", merged.Subject) } } // TestApplyTemplate_ReplyWarnsWhenTemplateHasRecipients verifies the warning // emitted for reply/reply-all with template-side tos/ccs/bccs. func TestApplyTemplate_ReplyWarnsWhenTemplateHasRecipients(t *testing.T) { tpl := &templatePayload{ Tos: []templateMailAddr{{Address: "t@x"}}, TemplateContent: "body", } merged := applyTemplate( templateShortcutReplyAll, tpl, "orig-to@x", "", "", "Re: foo", "", "", "", "", "", "", ) if len(merged.Warnings) == 0 { t.Errorf("expected warning, got none") } } // TestApplyTemplate_AttachmentClassification verifies inline/SMALL/LARGE are // routed into the correct output channels and anomalies surface warnings. func TestApplyTemplate_AttachmentClassification(t *testing.T) { tpl := &templatePayload{ Attachments: []templateAttachment{ {ID: "k1", Filename: "img.png", CID: "cid1", IsInline: true, AttachmentType: attachmentTypeSmall}, {ID: "k2", Filename: "file.pdf", IsInline: false, AttachmentType: attachmentTypeSmall}, {ID: "k3", Filename: "big.zip", IsInline: false, AttachmentType: attachmentTypeLarge}, // Anomaly: inline without CID → dropped with warning. {ID: "k4", Filename: "nocid.png", IsInline: true, AttachmentType: attachmentTypeSmall}, // Anomaly: inline but LARGE → dropped with warning. {ID: "k5", Filename: "huge.png", CID: "cid5", IsInline: true, AttachmentType: attachmentTypeLarge}, // Entry with no ID → silently dropped. {ID: "", Filename: "nope"}, }, } merged := applyTemplate( templateShortcutSend, tpl, "", "", "", "", "", "to@x", "", "", "", "", ) if len(merged.InlineAttachments) != 1 || merged.InlineAttachments[0].FileKey != "k1" { t.Errorf("expected 1 inline ref k1, got %#v", merged.InlineAttachments) } if len(merged.SmallAttachments) != 1 || merged.SmallAttachments[0].FileKey != "k2" { t.Errorf("expected 1 small ref k2, got %#v", merged.SmallAttachments) } if len(merged.LargeAttachmentIDs) != 1 || merged.LargeAttachmentIDs[0] != "k3" { t.Errorf("expected 1 large id k3, got %#v", merged.LargeAttachmentIDs) } // Two warnings (no-cid, inline-LARGE). Unknown IDs are silent. warnCnt := 0 for _, w := range merged.Warnings { if strings.Contains(w, "nocid.png") || strings.Contains(w, "huge.png") { warnCnt++ } } if warnCnt != 2 { t.Errorf("expected 2 anomaly warnings, got %d (%v)", warnCnt, merged.Warnings) } } // TestApplyTemplate_IsPlainTextPropagation verifies propagation of the // template's is_plain_text_mode into the merged result. func TestApplyTemplate_IsPlainTextPropagation(t *testing.T) { tpl := &templatePayload{IsPlainTextMode: true} merged := applyTemplate(templateShortcutSend, tpl, "", "", "", "", "", "to@x", "", "", "", "") if !merged.IsPlainTextMode { t.Errorf("expected IsPlainTextMode propagated") } } // --------------------------------------------------------------------------- // mergeTemplateBody — HTML & plain-text paths for send/reply/forward // --------------------------------------------------------------------------- // TestMergeTemplateBody_SendHTMLWithSeparator verifies thetpl
"} got := mergeTemplateBody(templateShortcutSend, tpl, "draft
", "") if !strings.Contains(got, "draft
") || !strings.Contains(got, "tpl
") { t.Errorf("both fragments should appear, got %q", got) } } // TestMergeTemplateBody_PlainTextSend verifies plain-text send uses \n\n // separator when draft body is non-empty. func TestMergeTemplateBody_PlainTextSend(t *testing.T) { tpl := &templatePayload{IsPlainTextMode: true, TemplateContent: "tpl"} if got := mergeTemplateBody(templateShortcutSend, tpl, "draft", ""); got != "draft\n\ntpl" { t.Errorf("got %q; want %q", got, "draft\n\ntpl") } if got := mergeTemplateBody(templateShortcutSend, tpl, " ", ""); got != "tpl" { t.Errorf("empty-draft should return tpl only, got %q", got) } } // TestMergeTemplateBody_UserBodyReplacesDraft verifies that a non-empty // userBody takes precedence over draftBody for merging. func TestMergeTemplateBody_UserBodyReplacesDraft(t *testing.T) { tpl := &templatePayload{TemplateContent: "tpl
"} got := mergeTemplateBody(templateShortcutSend, tpl, "draft
", "user
") if !strings.Contains(got, "user
") || strings.Contains(got, "draft
") { t.Errorf("userBody should replace draftBody, got %q", got) } } // TestMergeTemplateBody_ReplyPlainText verifies reply plain-text prepend. func TestMergeTemplateBody_ReplyPlainText(t *testing.T) { tpl := &templatePayload{IsPlainTextMode: true, TemplateContent: "tpl"} if got := mergeTemplateBody(templateShortcutReply, tpl, "draft", ""); got != "tpl\n\ndraft" { t.Errorf("reply plain-text: got %q; want %q", got, "tpl\n\ndraft") } if got := mergeTemplateBody(templateShortcutReply, tpl, "", ""); got != "tpl" { t.Errorf("reply empty draft should return tpl, got %q", got) } } // TestMergeTemplateBody_PlainTextStripsHTML verifies plain-text-mode templates // whose stored content is HTML-wrapped (per the preview-friendly storage // format) get their HTML stripped back to real newlines before injection, // so the recipient sees plain text instead of literaltpl
"} got := mergeTemplateBody(templateShortcutReply, tpl, "draft
", "") if !strings.Contains(got, "tpl
") || !strings.Contains(got, "draft
") { t.Errorf("reply HTML should contain both fragments, got %q", got) } // Empty draft body → just tpl. if got := mergeTemplateBody(templateShortcutReply, tpl, "", ""); got != "tpl
" { t.Errorf("empty draft body should return tpl, got %q", got) } } // --------------------------------------------------------------------------- // logTemplateInfo — uses a runtime with a stderr buffer // --------------------------------------------------------------------------- // TestLogTemplateInfo verifies log template info emits info: phase: k=v lines // in deterministic key order. func TestLogTemplateInfo(t *testing.T) { rt, _, stderr := newOutputRuntime(t) logTemplateInfo(rt, "create.dry_run", map[string]interface{}{ "name_len": 7, "attachments_total": 2, "inline_count": 1, }) got := stderr.String() if !strings.HasPrefix(got, "info: template create.dry_run: ") { t.Errorf("prefix wrong: %q", got) } // Keys are sorted alphabetically. if idx1, idx2, idx3 := strings.Index(got, "attachments_total="), strings.Index(got, "inline_count="), strings.Index(got, "name_len="); idx1 < 0 || idx2 < 0 || idx3 < 0 || !(idx1 < idx2 && idx2 < idx3) { t.Errorf("keys out of order: %q", got) } // nil runtime shouldn't panic. logTemplateInfo(nil, "x", nil) } // --------------------------------------------------------------------------- // applyTemplatePatchFile / buildTemplatePatchSkeleton — from mail_template_update.go // --------------------------------------------------------------------------- // TestApplyTemplatePatchFile_Overlay verifies that only non-nil fields overlay. func TestApplyTemplatePatchFile_Overlay(t *testing.T) { base := &templatePayload{ Name: "orig", Subject: "orig-subj", TemplateContent: "orig-body", IsPlainTextMode: false, Tos: []templateMailAddr{{Address: "t@x"}}, } newName := "new-name" applyTemplatePatchFile(base, &templatePatchFile{Name: &newName}) if base.Name != "new-name" { t.Errorf("Name not overlaid: %q", base.Name) } if base.Subject != "orig-subj" || base.TemplateContent != "orig-body" { t.Errorf("non-patched fields mutated: %+v", base) } // All fields patched at once. newSubject := "s" newContent := "c" truth := true newTos := []templateMailAddr{{Address: "new@x"}} newCcs := []templateMailAddr{{Address: "newcc@x"}} newBccs := []templateMailAddr{{Address: "newbcc@x"}} applyTemplatePatchFile(base, &templatePatchFile{ Subject: &newSubject, TemplateContent: &newContent, IsPlainTextMode: &truth, Tos: &newTos, Ccs: &newCcs, Bccs: &newBccs, }) if base.Subject != "s" || base.TemplateContent != "c" || !base.IsPlainTextMode { t.Errorf("full overlay failed: %+v", base) } if len(base.Tos) != 1 || base.Tos[0].Address != "new@x" { t.Errorf("Tos overlay failed: %#v", base.Tos) } if len(base.Ccs) != 1 || base.Ccs[0].Address != "newcc@x" { t.Errorf("Ccs overlay failed: %#v", base.Ccs) } if len(base.Bccs) != 1 || base.Bccs[0].Address != "newbcc@x" { t.Errorf("Bccs overlay failed: %#v", base.Bccs) } // nil patch is a no-op and must not panic. applyTemplatePatchFile(base, nil) } // TestBuildTemplatePatchSkeleton verifies build template patch skeleton. func TestBuildTemplatePatchSkeleton(t *testing.T) { sk := buildTemplatePatchSkeleton() for _, key := range []string{"name", "subject", "template_content", "is_plain_text_mode", "tos", "ccs", "bccs"} { if _, ok := sk[key]; !ok { t.Errorf("skeleton missing %q", key) } } // Should round-trip through json without errors. buf, err := json.Marshal(sk) if err != nil { t.Fatalf("marshal: %v", err) } if len(buf) < 50 { t.Errorf("suspiciously small skeleton: %s", buf) } }