mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 00:03:42 +00:00
* feat: fix the six defects reported in issue #439 by mapping the IMAP UNAVAILABLE, INUSE and NONEXISTENT response codes to retry-level errors instead of a critical reconnect prompt, synthesising a stable no-msgid key so one message with no Message-ID header can no longer 400 the internal map endpoint and wedge every later sync pass with its cursors held, adding mailhtml.FromText and HasContent so an API or agent-created step with a plain body stops shipping the composer's empty div placeholder as its text/html part (derived on create and plain-only update, exposed as body_html on update_campaign_step, dropped at send and preview time, and refused at campaign start with empty_step_body), honouring sender_strategy='explicit' in ResolveCampaignSenderPool and ValidateCampaignReady so an emptied explicit pool parks the campaign instead of widening it to every mailbox in the workspace, making the paused_no_accounts auto-pause loud with an error log line, an error-level activity-feed entry and an org-scoped CAMPAIGN_PAUSED realtime pulse, gating the admin sign-in's Turnstile widget on GET /v1/auth/config so a self-host with CAPTCHA_PROVIDER=none is not locked out, and parsing NATS_URL down to its host:port so a credentialed bus URL no longer reports NATS down * feat: act on the self-review of the issue #439 fixes by dropping the campaign wizard's own escapeHtml body_html builder, which entity-escaped the quotes in a conditional and made the template fail to parse at send time, and letting the backend's FromText render that part instead so wizard-written steps also get their bare URLs linked for click tracking, correcting the docs and openapi description that claimed an explicit sender pool never falls back when it still unions its tags as migration 000013 designed, extracting the duplicated blank-HTML-part guard into dropBlankHTMLPart shared by the send path and the preview, and recording why the no-msgid key keeps the folder name despite a RENAME changing it * feat: address the CodeRabbit review on the issue #439 fixes by holding the admin sign-in's Turnstile widget unmounted until /v1/auth/config resolves so an instance with no route to Cloudflare cannot raise a widget error on a screen nobody submitted, failing StartCampaign closed when the sequence read errors rather than skipping both the malformed-template and empty-body refusals, giving TCPCheck the default port its protocol assumes so a portless NATS_URL is no longer reported down, leaving a URL that carries a merge field unanchored because the send path renders bodies with text/template and a quoted contact value would break out of the href, and correcting the sequences guide and the Campaign and CampaignUpdate openapi descriptions that named the wrong tag field
109 lines
3.8 KiB
Go
109 lines
3.8 KiB
Go
package mailhtml
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFromTextKeepsLineStructure(t *testing.T) {
|
|
got := FromText("Hi Ana,\n\nQuick question about your team.")
|
|
want := "<div>Hi Ana,</div><div><br></div><div>Quick question about your team.</div>"
|
|
if got != want {
|
|
t.Errorf("FromText =\n%q\nwant\n%q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFromTextIsEmptyForEmptyInput(t *testing.T) {
|
|
for _, in := range []string{"", " ", "\n\n", "\r\n"} {
|
|
if got := FromText(in); got != "" {
|
|
t.Errorf("FromText(%q) = %q, want an empty body so nothing ships an HTML part", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The plain body is untrusted template text: it may contain angle brackets,
|
|
// ampersands, or a merge field someone pasted markup into.
|
|
func TestFromTextEscapes(t *testing.T) {
|
|
got := FromText(`Tom & Jerry <script>alert(1)</script>`)
|
|
if strings.Contains(got, "<script>") {
|
|
t.Errorf("FromText did not escape markup: %q", got)
|
|
}
|
|
if !strings.Contains(got, "&") {
|
|
t.Errorf("FromText did not escape the ampersand: %q", got)
|
|
}
|
|
}
|
|
|
|
// Click tracking rewrites hrefs, so a URL left as bare text is never tracked
|
|
// and never gets a UTM tag.
|
|
func TestFromTextLinkifiesBareURLs(t *testing.T) {
|
|
got := FromText("Book here: https://cal.example.com/ana?ref=a&b=c")
|
|
if !strings.Contains(got, `<a href="https://cal.example.com/ana?ref=a&b=c">`) {
|
|
t.Errorf("FromText did not anchor the URL: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFromTextLeavesSentencePunctuationOutOfTheLink(t *testing.T) {
|
|
got := FromText("See https://example.com.")
|
|
if !strings.Contains(got, `<a href="https://example.com">https://example.com</a>.`) {
|
|
t.Errorf("FromText swallowed the full stop into the href: %q", got)
|
|
}
|
|
}
|
|
|
|
// A step body is a Go template. Escaping the quotes in a conditional makes it
|
|
// fail to parse, which drops the send onto the naive renderer and ships the
|
|
// literal {{if ...}} text to the recipient.
|
|
func TestFromTextKeepsTemplateSyntaxIntact(t *testing.T) {
|
|
tmpl := `{{if eq .Company "Acme"}}Hi {{.FirstName}}{{end}}`
|
|
got := FromText(tmpl)
|
|
if !strings.Contains(got, tmpl) {
|
|
t.Errorf("FromText mangled the template:\n%q\nwant it to contain\n%q", got, tmpl)
|
|
}
|
|
}
|
|
|
|
func TestHasContent(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want bool
|
|
}{
|
|
{"empty string", "", false},
|
|
{"composer placeholder", "<div></div>", false},
|
|
{"nested empty blocks", "<div><p></p><p><br></p></div>", false},
|
|
{"whitespace only", "<div> </div>", false},
|
|
{"style block only", "<style>.a{color:red}</style>", false},
|
|
{"real copy", "<div>Hi Ana</div>", true},
|
|
{"image only", `<div><img src="https://example.com/a.png"></div>`, true},
|
|
{"image with no source or alt", "<div><img></div>", false},
|
|
{"rule", "<div><hr></div>", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := HasContent(c.body); got != c.want {
|
|
t.Errorf("%s: HasContent(%q) = %v, want %v", c.name, c.body, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The round trip the derivation relies on: whatever FromText writes for a
|
|
// non-empty body must read back as content, or the send-time guard would drop
|
|
// the part it just derived.
|
|
func TestFromTextOutputHasContent(t *testing.T) {
|
|
for _, in := range []string{"Hello", "a\n\nb", "https://example.com"} {
|
|
if !HasContent(FromText(in)) {
|
|
t.Errorf("FromText(%q) produced a body HasContent calls empty", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The send path renders the body with text/template, which escapes nothing by
|
|
// design, so an anchor built around a templated URL would let a contact value
|
|
// containing a quote break out of the href. Such a URL stays plain text.
|
|
func TestFromTextDoesNotAnchorATemplatedURL(t *testing.T) {
|
|
got := FromText("Book here: https://cal.example.com/?ref={{.Company}}")
|
|
if strings.Contains(got, "<a href") {
|
|
t.Errorf("FromText anchored a URL carrying a merge field: %q", got)
|
|
}
|
|
if !strings.Contains(got, "https://cal.example.com/?ref={{.Company}}") {
|
|
t.Errorf("FromText mangled the URL: %q", got)
|
|
}
|
|
}
|