mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 16:04:25 +00:00
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package tasks
|
|
|
|
import (
|
|
"html"
|
|
"strings"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// UnsubscribeLinkVar is the template variable a step can place by hand
|
|
// ({{.UnsubscribeLink}}); it resolves to the recipient's own signed link.
|
|
const UnsubscribeLinkVar = "UnsubscribeLink"
|
|
|
|
// unsubscribePathMarker is the path segment every minted link carries. Click
|
|
// tracking leaves such links alone so an opt-out is never counted as a click
|
|
// or bounced through a redirect.
|
|
const unsubscribePathMarker = "/unsubscribe/"
|
|
|
|
// optOutFooter renders the in-body opt-out for one recipient, as HTML and as
|
|
// plain text, or empty strings when the effective mode is off. Link mode with
|
|
// no link available (the instance has no public API URL) falls back to the
|
|
// text line so a recipient is never left without a way out.
|
|
func optOutFooter(settings models.UnsubscribeSettings, linkURL string) (htmlPart, plainPart string) {
|
|
switch settings.Mode {
|
|
case models.UnsubscribeModeOff:
|
|
return "", ""
|
|
case models.UnsubscribeModeLink:
|
|
if linkURL == "" {
|
|
break
|
|
}
|
|
intro := strings.TrimSpace(settings.LinkIntro)
|
|
text := strings.TrimSpace(settings.LinkText)
|
|
htmlPart = `<p style="font-size:12px;color:#64748b;margin-top:16px">` +
|
|
html.EscapeString(intro) + ` <a href="` + html.EscapeString(linkURL) + `" style="color:#64748b">` + html.EscapeString(text) + `</a></p>`
|
|
plainPart = intro + " " + text + ": " + linkURL
|
|
return htmlPart, strings.TrimSpace(plainPart)
|
|
}
|
|
line := strings.TrimSpace(settings.Text)
|
|
if line == "" {
|
|
return "", ""
|
|
}
|
|
return `<p style="font-size:12px;color:#64748b;margin-top:16px">` + html.EscapeString(line) + `</p>`, line
|
|
}
|
|
|
|
// appendOptOut adds the footer after everything else (signature included) so
|
|
// it sits where a reader expects an opt-out: last.
|
|
func appendOptOut(bodyHTML, bodyPlain string, settings models.UnsubscribeSettings, linkURL string) (string, string) {
|
|
htmlPart, plainPart := optOutFooter(settings, linkURL)
|
|
if htmlPart == "" {
|
|
return bodyHTML, bodyPlain
|
|
}
|
|
if bodyHTML != "" {
|
|
if strings.Contains(bodyHTML, "</body>") {
|
|
bodyHTML = strings.Replace(bodyHTML, "</body>", htmlPart+"</body>", 1)
|
|
} else {
|
|
bodyHTML += htmlPart
|
|
}
|
|
}
|
|
if bodyPlain != "" {
|
|
bodyPlain += "\n\n" + plainPart
|
|
}
|
|
return bodyHTML, bodyPlain
|
|
}
|
|
|
|
// linkifyUnsubscribeURL turns a hand-placed {{.UnsubscribeLink}} into a real
|
|
// link. The variable resolves to the recipient's signed URL, so a token
|
|
// dropped into prose ships as the bare API address (issue #341); this wraps
|
|
// every loose occurrence in an anchor labelled with the workspace's
|
|
// unsubscribe link text instead. A URL the author already put in an
|
|
// attribute (their own <a href>) is inside a tag and left untouched, and one
|
|
// sitting as the text of an existing anchor becomes that anchor's label
|
|
// rather than a nested link.
|
|
func linkifyUnsubscribeURL(bodyHTML, linkURL, linkText string) string {
|
|
if bodyHTML == "" || linkURL == "" || !strings.Contains(bodyHTML, linkURL) {
|
|
return bodyHTML
|
|
}
|
|
label := html.EscapeString(strings.TrimSpace(linkText))
|
|
if label == "" {
|
|
label = models.DefaultUnsubscribeLinkText
|
|
}
|
|
anchor := `<a href="` + html.EscapeString(linkURL) + `">` + label + `</a>`
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(bodyHTML) + len(anchor))
|
|
depth := 0 // open <a> elements around the current text node
|
|
for i := 0; i < len(bodyHTML); {
|
|
if bodyHTML[i] == '<' {
|
|
end := tagEnd(bodyHTML[i:])
|
|
if end < 0 {
|
|
b.WriteString(bodyHTML[i:]) // unterminated tag: copy the rest verbatim
|
|
break
|
|
}
|
|
tag := bodyHTML[i : i+end+1]
|
|
switch {
|
|
case isTagStart(tag, "a"):
|
|
depth++
|
|
case isTagStart(tag, "/a"):
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
b.WriteString(tag)
|
|
i += end + 1
|
|
continue
|
|
}
|
|
stop := len(bodyHTML)
|
|
if next := strings.IndexByte(bodyHTML[i:], '<'); next >= 0 {
|
|
stop = i + next
|
|
}
|
|
with := anchor
|
|
if depth > 0 {
|
|
with = label
|
|
}
|
|
b.WriteString(strings.ReplaceAll(bodyHTML[i:stop], linkURL, with))
|
|
i = stop
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// tagEnd returns the index of the '>' that closes the tag starting at s[0], or
|
|
// -1 when there is none. A '>' inside a quoted attribute value does not close
|
|
// anything: reading one as the end split `<a title="x > y" href="URL">` into a
|
|
// tag and a run of text, and the href in that "text" was then rewritten into a
|
|
// dead link.
|
|
func tagEnd(s string) int {
|
|
var quote byte
|
|
for i := 1; i < len(s); i++ {
|
|
switch c := s[i]; {
|
|
case quote != 0:
|
|
if c == quote {
|
|
quote = 0
|
|
}
|
|
case c == '"' || c == '\'':
|
|
quote = c
|
|
case c == '>':
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// isTagStart reports whether tag (a full "<...>" slice) is the named tag,
|
|
// case-insensitively: isTagStart(`<A HREF="x">`, "a") and
|
|
// isTagStart("</A>", "/a") are both true.
|
|
func isTagStart(tag, name string) bool {
|
|
if len(tag) < len(name)+2 || !strings.EqualFold(tag[1:1+len(name)], name) {
|
|
return false
|
|
}
|
|
switch c := tag[1+len(name)]; c {
|
|
case '>', '/', ' ', '\t', '\n', '\r', '\f':
|
|
return true
|
|
}
|
|
return false
|
|
}
|