mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 08:05:05 +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
133 lines
5.0 KiB
Go
133 lines
5.0 KiB
Go
package mailhtml
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
nethtml "golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
// bareURL matches a link a plain-text author typed with no markup around it.
|
|
// Trailing sentence punctuation is deliberately outside the match: "see
|
|
// https://example.com." ends in a full stop, not in a URL.
|
|
var bareURL = regexp.MustCompile(`https?://[^\s<>"']+[^\s<>"'.,;:!?)\]]`)
|
|
|
|
// FromText renders a plain-text body as the HTML part that ships beside it.
|
|
//
|
|
// A step written through the API or an agent tool has a plain body and no
|
|
// HTML, and the send path puts body_html on the wire as the text/html
|
|
// alternative. Every modern client prefers that part, so a step whose HTML was
|
|
// still the composer's empty <div></div> placeholder arrived as a blank
|
|
// message with the real copy only in the fallback nobody reads.
|
|
//
|
|
// The output is the shape the dashboard composer produces (one <div> per
|
|
// line), so a body derived here opens in the editor unchanged rather than as
|
|
// something the author did not write. Bare URLs become anchors because click
|
|
// tracking rewrites hrefs: a link left as text is a link that is never
|
|
// tracked and never gets a UTM tag.
|
|
func FromText(plain string) string {
|
|
// Normalise the line endings first: a CRLF body would otherwise leave a
|
|
// stray carriage return inside every div.
|
|
text := strings.ReplaceAll(strings.ReplaceAll(plain, "\r\n", "\n"), "\r", "\n")
|
|
if strings.TrimSpace(text) == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
// Gmail's own shape for a blank line. An empty <div> collapses to
|
|
// nothing in several clients, which loses the author's spacing.
|
|
b.WriteString("<div><br></div>")
|
|
continue
|
|
}
|
|
b.WriteString("<div>")
|
|
b.WriteString(linkifyEscaped(line))
|
|
b.WriteString("</div>")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// textEscaper escapes the three characters that are markup in element content.
|
|
//
|
|
// Quotes are deliberately NOT escaped, which is why html.EscapeString is not
|
|
// used here: a step body is a Go template, and turning `{{if eq .Company
|
|
// "Acme"}}` into `"Acme"` makes it fail to parse, which drops the send
|
|
// onto the naive renderer and ships the literal template text to the
|
|
// recipient. Nothing this writes can land in an attribute: the URL pattern
|
|
// excludes both quote characters, so the href below is always quote-free.
|
|
var textEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
|
|
|
// linkifyEscaped escapes one line of plain text and wraps its bare URLs in
|
|
// anchors. Escaping happens first and the anchor markup is written around the
|
|
// escaped text, so nothing the author typed can become markup.
|
|
func linkifyEscaped(line string) string {
|
|
escaped := textEscaper.Replace(line)
|
|
return bareURL.ReplaceAllStringFunc(escaped, func(match string) string {
|
|
// A URL carrying a merge field is left as text. The send path renders
|
|
// the body with text/template, which by design performs no escaping
|
|
// (see internal/tasks/template.go), so a contact value containing a
|
|
// quote would break out of the href this would otherwise build. Plain
|
|
// bodies had no anchors at all before, so declining to add one here
|
|
// costs nothing that existed.
|
|
if strings.Contains(match, "{{") {
|
|
return match
|
|
}
|
|
// Otherwise the href is literal text the author typed, already through
|
|
// textEscaper and holding no quote, so it is attribute-safe as it is.
|
|
return `<a href="` + match + `">` + match + `</a>`
|
|
})
|
|
}
|
|
|
|
// contentTags are elements that are content in themselves: an email may
|
|
// legitimately be one image, a horizontal rule between two blocks, or a table
|
|
// of them, and none of those carry text.
|
|
var contentTags = map[atom.Atom]bool{
|
|
atom.Img: true, atom.Video: true, atom.Audio: true, atom.Hr: true,
|
|
}
|
|
|
|
// HasContent reports whether an HTML body would render anything a recipient
|
|
// can see: visible text, or an image or rule standing in for it.
|
|
//
|
|
// It exists because "" is not the only empty body. A step created through the
|
|
// API carries the composer's <div></div> placeholder, which is non-empty as a
|
|
// string, passes every len() check, and ships as a completely blank email.
|
|
func HasContent(bodyHTML string) bool {
|
|
if strings.TrimSpace(bodyHTML) == "" {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(ToPlainText(bodyHTML)) != "" {
|
|
return true
|
|
}
|
|
doc, err := nethtml.Parse(strings.NewReader(bodyHTML))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
root := findElement(doc, atom.Body)
|
|
if root == nil {
|
|
root = doc
|
|
}
|
|
return hasContentNode(root)
|
|
}
|
|
|
|
func hasContentNode(n *nethtml.Node) bool {
|
|
if n.Type == nethtml.ElementNode {
|
|
if textSkip[n.DataAtom] {
|
|
return false
|
|
}
|
|
if contentTags[n.DataAtom] && !isHidden(n) {
|
|
// An <img> with no source renders as nothing (or as a broken-image
|
|
// placeholder), which is not content either.
|
|
if n.DataAtom != atom.Img || strings.TrimSpace(attrOf(n, "src")) != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if hasContentNode(c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|