package tasks import ( "reflect" "strings" "testing" "time" "github.com/warmbly/warmbly/internal/models" ) func TestRenderTemplate_Sender(t *testing.T) { stamp := time.Date(2026, 10, 5, 15, 0, 0, 0, time.FixedZone("local", 3600)) account := &models.Email{ Name: " Tareque M. ", Email: "tareque@example.com", SendAsEmail: "hello@example.com", ReplyTo: "replies@example.com", Provider: "gmail", Timezone: "Europe/Budapest", SignaturePlain: "Best, Tareque", SignatureHTML: "Tareque", SignatureSync: true, CampaignLimit: 25, Tags: []string{"sales", "team"}, CreatedAt: stamp, Warmup: &stamp, } contact := models.Contact{FirstName: "Alex", Email: "alex@example.org", CustomFields: map[string]string{ "role": "CTO", "Job Title": "Founder", "Sender": "not the sender", "FirstName": "not Alex", }} context := templateContext(account, "https://example.com/unsubscribe/tok") cases := []struct{ name, template, want string }{ {"identity", `Hi {{.FirstName}}, I'm {{.Sender.Name}} <{{.Sender.Email}}>`, "Hi Alex, I'm Tareque M. "}, {"addresses", `{{.Email}}|{{.Sender.MailboxEmail}}|{{.Sender.SendAsEmail}}|{{.Sender.ReplyTo}}`, "alex@example.org|tareque@example.com|hello@example.com|replies@example.com"}, {"contact and link", `{{.role}}|{{.Job Title}}|{{.UnsubscribeLink}}`, "CTO|Founder|https://example.com/unsubscribe/tok"}, {"native types", `{{if .Sender.SignatureSync}}{{.Sender.SignaturePlain}}{{end}}|{{if gt .Sender.CampaignLimit 20}}high{{end}}`, "Best, Tareque|high"}, {"with and range", `{{with .Sender}}{{.Name}}:{{range .Tags}}{{.}};{{end}}{{end}}`, "Tareque M.:sales;team;"}, {"helpers", `{{.Sender.Name | upper}}|{{.Sender.Vendor | default "direct"}}`, "TAREQUE M.|direct"}, {"timestamps", `{{.Sender.CreatedAt}}|{{.Sender.Warmup}}|{{.Sender.WarmupPausedAt}}`, "2026-10-05T14:00:00Z|2026-10-05T14:00:00Z|"}, {"html", `{{.Sender.SignatureHTML}}`, "Tareque"}, {"malformed fallback", `{{if .Company}}Hi {{.FirstName}}, {{.Sender.Name}} at {{.Sender.Email}}`, "{{if .Company}}Hi Alex, Tareque M. at hello@example.com"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := RenderTemplateWith(tc.template, contact, context); got != tc.want { t.Fatalf("got %q, want %q", got, tc.want) } }) } account.Tags[0] = "changed" if context.Sender.Tags[0] != "sales" { t.Fatal("sender tags share the mailbox's mutable slice") } } func TestTemplateSender_EmptyAndEffectiveIdentity(t *testing.T) { if got := RenderTemplate(`{{.Sender.Name}}|{{.Sender.Email}}|{{.Sender.Name | default "our team"}}|{{if .Sender.SignatureSync}}yes{{else}}no{{end}}`, models.Contact{}); got != "||our team|no" { t.Fatalf("empty sender: %q", got) } account := &models.Email{Name: "John S.", Email: "john@example.com", OrgTimezone: "America/New_York"} sender := templateContext(account, "").Sender if sender.Email != "john@example.com" || sender.MailboxEmail != sender.Email || sender.SendAsEmail != "" || sender.ReplyTo != "" || sender.Timezone != "America/New_York" { t.Fatalf("incorrect effective sender: %+v", sender) } account.ReplyTo = "john@example.com" if templateContext(account, "").Sender.ReplyTo != "" { t.Fatal("redundant Reply-To did not match the outbound header") } } func TestAIVarAvailableVarsSkipsReservedSenderNamespace(t *testing.T) { vars := aiVarAvailableVars(&models.Contact{CustomFields: map[string]string{"Sender": "not a mailbox", "UnsubscribeLink": "not a link", "role": "CTO"}}) for _, token := range vars { if token == "{{.Sender}}" || token == "{{.UnsubscribeLink}}" { t.Fatalf("AI advertised a reserved contact field: %s", token) } } if !strings.Contains(strings.Join(vars, " "), "{{.role}}") { t.Fatal("valid custom field was excluded") } } func TestTemplateSender_ExcludesInternalFieldsAndMethods(t *testing.T) { typ := reflect.TypeOf(TemplateSender{}) for _, field := range []string{"ID", "UserID", "OrganizationID", "WorkerID", "DomainGrantID", "VendorConnectionID", "LastID", "OrgTimezone", "Password", "AccessToken", "RefreshToken"} { if _, ok := typ.FieldByName(field); ok { t.Errorf("unsafe sender field %s", field) } } if typ.NumMethod() != 0 || reflect.PointerTo(typ).NumMethod() != 0 { t.Fatal("sender exposes mailbox methods") } for _, field := range []string{"UserID", "WorkerID", "AccessToken", "SendFrom"} { tmpl := "{{.Sender." + field + "}}" preview := previewTemplatesWith(tmpl, "", "", models.Contact{}, templateContext(&models.Email{UserID: "private"}, "")) if preview.Subject != tmpl || len(preview.Unresolved) != 1 { t.Errorf("unknown sender field %s should remain unresolved: %+v", field, preview) } } } func TestTemplateSender_MapsEveryAllowedMailboxField(t *testing.T) { account := &models.Email{} mailbox := reflect.ValueOf(account).Elem() stamp := time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC) for i := 0; i < mailbox.NumField(); i++ { field := mailbox.Field(i) switch field.Kind() { case reflect.String: field.SetString(mailbox.Type().Field(i).Name + "-value") case reflect.Bool: field.SetBool(true) case reflect.Int: field.SetInt(37) default: switch field.Type() { case reflect.TypeOf(stamp): field.Set(reflect.ValueOf(stamp)) case reflect.TypeOf(&stamp): field.Set(reflect.ValueOf(&stamp)) case reflect.TypeOf([]string{}): field.Set(reflect.ValueOf([]string{"sales"})) } } } sender := reflect.ValueOf(templateContext(account, "").Sender) for i := 0; i < sender.NumField(); i++ { name := sender.Type().Field(i).Name mailboxName := name if name == "MailboxEmail" { mailboxName = "Email" } var want any = mailbox.FieldByName(mailboxName).Interface() switch v := want.(type) { case time.Time: want = templateTime(&v) case *time.Time: want = templateTime(v) } if name == "Email" { want = account.SendFrom() } if name == "ReplyTo" { want = account.ReplyToHeader() } if !reflect.DeepEqual(sender.Field(i).Interface(), want) { t.Errorf("Sender.%s = %v, want %v", name, sender.Field(i).Interface(), want) } } } func TestRenderTemplate_RangeOverNumberRefused(t *testing.T) { tmpl := "Hi {{.FirstName}}{{range 100000000000}}x{{end}}" if err := TemplateError(tmpl); err == nil { t.Fatal("TemplateError accepted a range over a number") } done := make(chan string, 1) go func() { done <- RenderTemplate(tmpl, models.Contact{FirstName: "Ann"}) }() select { case out := <-done: if strings.Contains(out, "xx") { t.Fatalf("range executed: %d bytes", len(out)) } case <-time.After(5 * time.Second): t.Fatal("render of a range over a number did not return") } } func TestRenderTemplate_RangeOverMapStillRenders(t *testing.T) { contact := models.Contact{FirstName: "Ann"} out := RenderTemplate(`{{range $k, $v := .}}{{if eq $k "FirstName"}}{{$v}}{{end}}{{end}}`, contact) if out != "Ann" { t.Fatalf("got %q", out) } } func TestRenderTemplate_BasicVariables(t *testing.T) { contact := models.Contact{ FirstName: "Alice", LastName: "Smith", Email: "alice@example.com", Company: "Acme Corp", Phone: "+1234567890", } tmpl := "Hi {{.FirstName}} {{.LastName}}, welcome from {{.Company}}!" result := RenderTemplate(tmpl, contact) expected := "Hi Alice Smith, welcome from Acme Corp!" if result != expected { t.Errorf("expected %q, got %q", expected, result) } } func TestRenderTemplate_CustomFields(t *testing.T) { contact := models.Contact{ FirstName: "Bob", CustomFields: map[string]string{"role": "Engineer", "city": "Berlin"}, } tmpl := "Hey {{.FirstName}}, you work as a {{.role}} in {{.city}}" result := RenderTemplate(tmpl, contact) if !strings.Contains(result, "Engineer") || !strings.Contains(result, "Berlin") { t.Errorf("custom fields not rendered: %q", result) } } func TestRenderTemplate_EmptyContact(t *testing.T) { contact := models.Contact{} tmpl := "Hello {{.FirstName}}" result := RenderTemplate(tmpl, contact) if result != "Hello " { t.Errorf("expected empty first name, got %q", result) } } func TestRenderTemplate_NoPlaceholders(t *testing.T) { contact := models.Contact{FirstName: "Test"} tmpl := "Just a plain text email with no variables." result := RenderTemplate(tmpl, contact) if result != tmpl { t.Errorf("expected unchanged text, got %q", result) } } func TestRenderTemplate_ConditionalIfSet(t *testing.T) { tmpl := "Hi {{.FirstName}},{{if .Company}} saw {{.Company}} is hiring.{{end}}" with := RenderTemplate(tmpl, models.Contact{FirstName: "Alex", Company: "Acme"}) if with != "Hi Alex, saw Acme is hiring." { t.Errorf("if-set (present) wrong: %q", with) } without := RenderTemplate(tmpl, models.Contact{FirstName: "Alex"}) if without != "Hi Alex," { t.Errorf("if-set (absent) wrong: %q", without) } } func TestRenderTemplate_IfElse(t *testing.T) { tmpl := "{{if .FirstName}}Hi {{.FirstName}}{{else}}Hi there{{end}}," if got := RenderTemplate(tmpl, models.Contact{FirstName: "Sam"}); got != "Hi Sam," { t.Errorf("if branch wrong: %q", got) } if got := RenderTemplate(tmpl, models.Contact{}); got != "Hi there," { t.Errorf("else branch wrong: %q", got) } } func TestRenderTemplate_EqOnCustomField(t *testing.T) { tmpl := `{{if eq .city "Berlin"}}in town{{else}}remote{{end}}` yes := RenderTemplate(tmpl, models.Contact{CustomFields: map[string]string{"city": "Berlin"}}) if yes != "in town" { t.Errorf("eq match wrong: %q", yes) } no := RenderTemplate(tmpl, models.Contact{CustomFields: map[string]string{"city": "Paris"}}) if no != "remote" { t.Errorf("eq non-match wrong: %q", no) } } func TestRenderTemplate_MissingKeyRendersEmpty(t *testing.T) { // An unknown token renders empty (missingkey=zero) rather than leaking. got := RenderTemplate("X{{.Nope}}Y", models.Contact{FirstName: "A"}) if got != "XY" { t.Errorf("missing key should be empty: %q", got) } } func TestRenderTemplate_MalformedFallsBack(t *testing.T) { // An {{if}} with no {{end}} must not hard-fail; it falls back to naive // substitution so standard variables still resolve. got := RenderTemplate("Hi {{.FirstName}} {{if .Company}}oops", models.Contact{FirstName: "Bo", Company: "Acme"}) if !strings.Contains(got, "Bo") { t.Errorf("malformed template should still substitute variables: %q", got) } } func TestRenderTemplate_NonIdentifierCustomKey(t *testing.T) { // Custom keys with spaces can't use selector syntax; the pre-pass substitutes // {{.Job Title}} literally so it still renders. got := RenderTemplate("Role: {{.Job Title}}", models.Contact{ CustomFields: map[string]string{"Job Title": "CTO"}, }) if got != "Role: CTO" { t.Errorf("non-identifier custom key wrong: %q", got) } } func TestGenerateConversationEmail_NewEmail(t *testing.T) { conv := Conversation{ Theme: "test", Description: "This is a test conversation.", Messages: []string{"What do you think?"}, } account := models.Email{Name: "John Doe", Email: "john@test.com"} body := GenerateConversationEmail(conv, account, false) if !strings.Contains(body, "This is a test conversation.") { t.Errorf("body should contain description: %q", body) } if !strings.Contains(body, "John Doe") { t.Errorf("body should contain signature: %q", body) } } func TestGenerateConversationEmail_Reply(t *testing.T) { conv := Conversation{ Theme: "test", Description: "Test desc.", Messages: []string{"Sure thing!"}, } account := models.Email{Name: "Jane", Email: "jane@test.com"} body := GenerateConversationEmail(conv, account, true) if strings.Contains(body, "Test desc.") { t.Errorf("reply should not contain description: %q", body) } if !strings.Contains(body, "Jane") { t.Errorf("reply should contain signature: %q", body) } } func TestGenerateConversationEmail_FallbackSignature(t *testing.T) { conv := Conversation{Description: "Hello.", Messages: []string{"Hi"}} account := models.Email{Email: "anon@test.com"} // No Name set body := GenerateConversationEmail(conv, account, false) if !strings.Contains(body, "anon@test.com") { t.Errorf("should fall back to email as signature: %q", body) } } func TestExtractPlainTextFromHTML(t *testing.T) { html := "

Hello world


Second paragraph

" plain := ExtractPlainTextFromHTML(html) if !strings.Contains(plain, "Hello") || !strings.Contains(plain, "world") { t.Errorf("plain text should contain content: %q", plain) } if strings.Contains(plain, "

") || strings.Contains(plain, "") { t.Errorf("plain text should not contain HTML tags: %q", plain) } } func TestGenerateWarmupSubject_NotEmpty(t *testing.T) { subject := generateWarmupSubject() if subject == "" { t.Error("warmup subject should not be empty") } } func TestRandomWarmupConversation_HasContent(t *testing.T) { conv := randomWarmupConversation() if conv.Theme == "" { t.Error("conversation should have a theme") } if conv.Description == "" { t.Error("conversation should have a description") } if len(conv.Messages) == 0 { t.Error("conversation should have at least one message") } } func TestConversationForTheme_MatchesKnownTheme(t *testing.T) { conv := conversationForTheme("productivity") if conv.Theme != "productivity" { t.Errorf("expected theme productivity, got %q", conv.Theme) } } func TestConversationForTheme_FallsBackOnUnknown(t *testing.T) { conv := conversationForTheme("not-a-real-theme") if conv.Theme == "" { t.Error("fallback conversation should still have a theme") } } func TestConversationForTheme_EmptyReturnsRandom(t *testing.T) { conv := conversationForTheme("") if conv.Theme == "" { t.Error("empty theme should return a random conversation with a theme") } } func TestGenerateMessageID_Format(t *testing.T) { mid := generateMessageID("user@example.com") if !strings.HasSuffix(mid, "@example.com>") { t.Errorf("message ID should end with domain, got %q", mid) } if !strings.HasPrefix(mid, "<") { t.Errorf("message ID should start with <, got %q", mid) } } // The HTML signature is a block with a top margin, not

stacked on the // body's own trailing margin, and it goes inside the document when there is one. func TestAddSignatureSpacing(t *testing.T) { out := AddSignature("

Hi

", "

Ana

", true) if strings.Contains(out, "
") { t.Errorf("signature still separated by breaks: %q", out) } if !strings.Contains(out, `

Ana

`) { t.Errorf("signature not wrapped in its own block: %q", out) } doc := AddSignature("

Hi

", "

Ana

", true) if !strings.HasSuffix(doc, "") || strings.Index(doc, "Ana") > strings.Index(doc, "") { t.Errorf("signature landed outside the document: %q", doc) } // HTML tag names are case-insensitive, so a pasted counts too. upper := AddSignature("

Hi

", "

Ana

", true) if !strings.HasSuffix(upper, "") || strings.Index(upper, "Ana") > strings.Index(upper, "") { t.Errorf("signature landed outside an upper-case document: %q", upper) } spaced := AddSignature("

Hi

", "

Ana

", true) if strings.Index(spaced, "Ana") > strings.Index(spaced, "") { t.Errorf("signature landed outside a spaced closing tag: %q", spaced) } if plain := AddSignature("Hi", "Ana", false); plain != "Hi\n\nAna" { t.Errorf("plain-text spacing changed: %q", plain) } if none := AddSignature("

Hi

", "", true); none != "

Hi

" { t.Errorf("empty signature altered the body: %q", none) } } // The opt-out footer still lands after the signature once both are applied to a // body carrying a , which is the order a reader expects. func TestSignatureThenOptOutOrderInsideDocument(t *testing.T) { body := AddSignature("

Hi

", "

Ana

", true) body, _ = appendOptOut(body, "", models.UnsubscribeSettings{Mode: models.UnsubscribeModeText, Text: "Reply stop."}, "") sig, foot := strings.Index(body, "Ana"), strings.Index(body, "Reply stop.") if sig < 0 || foot < 0 || sig > foot { t.Fatalf("footer did not follow the signature: %q", body) } if strings.Index(body, "") < foot { t.Fatalf("footer landed outside the document: %q", body) } }