mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
messages: switch to new template package
rewrote and put in its own repo. Now supports {if a ==/!= b}, string
literals ({if a == "b"}), else/else if and nested if statements.
This commit is contained in:
@@ -372,9 +372,11 @@ var customContent = map[string]CustomContentInfo{
|
|||||||
},
|
},
|
||||||
Variables: []string{
|
Variables: []string{
|
||||||
"myAccountURL",
|
"myAccountURL",
|
||||||
|
"profile",
|
||||||
},
|
},
|
||||||
Placeholders: map[string]any{
|
Placeholders: map[string]any{
|
||||||
"myAccountURL": "https://example.url/my/account",
|
"myAccountURL": "https://example.url/my/account",
|
||||||
|
"profile": "Default User Profile",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
6
email.go
6
email.go
@@ -19,6 +19,8 @@ import (
|
|||||||
textTemplate "text/template"
|
textTemplate "text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sTemplate "github.com/hrfee/simple-template"
|
||||||
|
|
||||||
"github.com/gomarkdown/markdown"
|
"github.com/gomarkdown/markdown"
|
||||||
"github.com/gomarkdown/markdown/html"
|
"github.com/gomarkdown/markdown/html"
|
||||||
"github.com/hrfee/jfa-go/easyproxy"
|
"github.com/hrfee/jfa-go/easyproxy"
|
||||||
@@ -251,13 +253,13 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten
|
|||||||
Subject: contentInfo.Subject(emailer.config, &emailer.lang),
|
Subject: contentInfo.Subject(emailer.config, &emailer.lang),
|
||||||
}
|
}
|
||||||
// Template the subject for bonus points
|
// Template the subject for bonus points
|
||||||
if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil {
|
if subject, err := sTemplate.Template(msg.Subject, data); err == nil {
|
||||||
msg.Subject = subject
|
msg.Subject = subject
|
||||||
}
|
}
|
||||||
if cc.Enabled {
|
if cc.Enabled {
|
||||||
// Use template email, rather than the built-in's email file.
|
// Use template email, rather than the built-in's email file.
|
||||||
contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
|
contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
|
||||||
content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data)
|
content, err := sTemplate.Template(cc.Content, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
|
emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
|
||||||
return msg, err
|
return msg, err
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -95,6 +95,7 @@ require (
|
|||||||
github.com/google/flatbuffers v25.9.23+incompatible // indirect
|
github.com/google/flatbuffers v25.9.23+incompatible // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/hrfee/simple-template v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -199,6 +199,10 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
|||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
|
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
|
||||||
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||||
|
github.com/hrfee/simple-template v1.0.0 h1:Yyq36RDZF9fT5SFpgFwgzk6ywQy2fdYa/Tl+jkcnF7c=
|
||||||
|
github.com/hrfee/simple-template v1.0.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
|
||||||
|
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
|
||||||
|
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
|
||||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
|
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
|
||||||
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||||
|
|||||||
147
template.go
147
template.go
@@ -1,147 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
func truthy(val interface{}) bool {
|
|
||||||
switch v := val.(type) {
|
|
||||||
case string:
|
|
||||||
return v != ""
|
|
||||||
case bool:
|
|
||||||
return v
|
|
||||||
case int:
|
|
||||||
return v != 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Templater for custom emails.
|
|
||||||
// Slices "variables", "conditionals", and map "values" should NOT wrap names in { and }.
|
|
||||||
// Variables should be written as {varName}.
|
|
||||||
// If statements should be written as {if (!)varName}...{endif}.
|
|
||||||
// Strings are true if != "", ints are true if != 0.
|
|
||||||
// Errors returned are likely warnings only.
|
|
||||||
func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) (string, error) {
|
|
||||||
// minimum length for templatable content (albeit just "{}" -> "")
|
|
||||||
if len(content) < 2 {
|
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
ifStart, ifEnd := -1, -1
|
|
||||||
ifTrue := false
|
|
||||||
invalidIf := false
|
|
||||||
previousEnd := -2
|
|
||||||
blockRawStart := -1
|
|
||||||
blockContentStart, blockContentEnd := -1, -1
|
|
||||||
varStart, varEnd := -1, -1
|
|
||||||
varName := ""
|
|
||||||
out := ""
|
|
||||||
var err error = nil
|
|
||||||
|
|
||||||
oob := func(i int) bool { return i < 0 || i >= len(content) }
|
|
||||||
|
|
||||||
for i, c := range content {
|
|
||||||
if c == '{' {
|
|
||||||
blockContentStart = i + 1
|
|
||||||
blockRawStart = i
|
|
||||||
if content[i+1] == '{' {
|
|
||||||
err = fmt.Errorf(`double braces ("{{") at position %d, use single brace only`, i)
|
|
||||||
blockContentStart++
|
|
||||||
}
|
|
||||||
for !oob(blockContentStart) && content[blockContentStart] == ' ' {
|
|
||||||
blockContentStart++
|
|
||||||
}
|
|
||||||
if oob(blockContentStart) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !oob(blockContentStart+3) && content[blockContentStart:blockContentStart+3] == "if " {
|
|
||||||
varStart = blockContentStart + 3
|
|
||||||
for content[varStart] == ' ' {
|
|
||||||
varStart++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ifStart == -1 && (oob(i-1) || content[i-1] != '{') {
|
|
||||||
out += content[previousEnd+2 : i]
|
|
||||||
}
|
|
||||||
if invalidIf || oob(blockContentStart+5) || content[blockContentStart:blockContentStart+5] != "endif" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ifEnd = i - 1
|
|
||||||
if ifTrue {
|
|
||||||
toAppend, subErr := templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values)
|
|
||||||
out += toAppend
|
|
||||||
if subErr != nil {
|
|
||||||
err = subErr
|
|
||||||
}
|
|
||||||
ifTrue = false
|
|
||||||
}
|
|
||||||
} else if c == '}' {
|
|
||||||
doubleBraced := !oob(i+1) && content[i+1] == '}'
|
|
||||||
if doubleBraced {
|
|
||||||
err = fmt.Errorf(`double braces ("}}") at position %d, use single brace only`, i)
|
|
||||||
}
|
|
||||||
if !oob(i-1) && content[i-1] == '}' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if varStart != -1 {
|
|
||||||
ifStart = i + 1
|
|
||||||
varEnd = i - 1
|
|
||||||
for !oob(varEnd) && content[varEnd] == ' ' {
|
|
||||||
varEnd--
|
|
||||||
}
|
|
||||||
varName = content[varStart : varEnd+1]
|
|
||||||
positive := true
|
|
||||||
if varName[0] == '!' {
|
|
||||||
positive = false
|
|
||||||
varName = varName[1:]
|
|
||||||
}
|
|
||||||
validVar := slices.Contains(conditionals, varName)
|
|
||||||
if validVar {
|
|
||||||
ifTrue = positive == truthy(values[varName])
|
|
||||||
} else {
|
|
||||||
invalidIf = true
|
|
||||||
ifStart, ifEnd = -1, -1
|
|
||||||
}
|
|
||||||
varStart, varEnd = -1, -1
|
|
||||||
}
|
|
||||||
blockContentEnd = i - 1
|
|
||||||
for content[blockContentEnd] == ' ' {
|
|
||||||
blockContentEnd--
|
|
||||||
}
|
|
||||||
previousEnd = i - 1
|
|
||||||
// Skip the extra brace
|
|
||||||
if doubleBraced {
|
|
||||||
previousEnd++
|
|
||||||
}
|
|
||||||
if !oob(blockContentEnd-4) && !oob(blockContentEnd+1) && content[blockContentEnd-4:blockContentEnd+1] == "endif" && !invalidIf {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
varName = content[blockContentStart : blockContentEnd+1]
|
|
||||||
blockContentStart, blockContentEnd = -1, -1
|
|
||||||
blockRawStart = -1
|
|
||||||
if ifStart != -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
validVar := slices.Contains(variables, varName)
|
|
||||||
if !validVar {
|
|
||||||
out += "{" + varName + "}"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out += fmt.Sprint(values[varName])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if blockContentStart != -1 && blockContentEnd == -1 {
|
|
||||||
err = fmt.Errorf(`incomplete block (single "{") near position %d`, blockContentStart)
|
|
||||||
// Include the brace, maybe the user wants it.
|
|
||||||
previousEnd = blockRawStart - 2
|
|
||||||
}
|
|
||||||
if previousEnd+1 != len(content)-1 {
|
|
||||||
out += content[previousEnd+2:]
|
|
||||||
|
|
||||||
}
|
|
||||||
if out == "" {
|
|
||||||
return content, err
|
|
||||||
}
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
128
template_test.go
128
template_test.go
@@ -1,128 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// In == Out when nothing is meant to be templated.
|
|
||||||
func TestBlankTemplate(t *testing.T) {
|
|
||||||
in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.`
|
|
||||||
|
|
||||||
out, err := templateEmail(in, []string{}, []string{}, map[string]any{})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %+v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out != in {
|
|
||||||
t.Fatalf(`returned string doesn't match input: "%+v" != "%+v"`, out, in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConditional(isTrue bool, t *testing.T) {
|
|
||||||
in := `Success, {username}! Your account has been created. {if myCondition}Log in at {myAccountURL} with username {username} to get started.{endif}`
|
|
||||||
|
|
||||||
vars := []string{"username", "myAccountURL", "myCondition"}
|
|
||||||
conds := vars
|
|
||||||
vals := map[string]any{
|
|
||||||
"username": "TemplateUsername",
|
|
||||||
"myAccountURL": "TemplateURL",
|
|
||||||
"myCondition": isTrue,
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := templateEmail(in, vars, conds, vals)
|
|
||||||
|
|
||||||
target := ""
|
|
||||||
if isTrue {
|
|
||||||
target = `Success, {username}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.`
|
|
||||||
} else {
|
|
||||||
target = `Success, {username}! Your account has been created. `
|
|
||||||
}
|
|
||||||
|
|
||||||
target = strings.ReplaceAll(target, "{username}", vals["username"].(string))
|
|
||||||
target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %+v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out != target {
|
|
||||||
t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionalTrue(t *testing.T) {
|
|
||||||
testConditional(true, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionalFalse(t *testing.T) {
|
|
||||||
testConditional(false, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template mistakenly double-braced values, but return a warning.
|
|
||||||
func TestTemplateDoubleBraceGracefulHandling(t *testing.T) {
|
|
||||||
in := `Success, {{username}}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.`
|
|
||||||
|
|
||||||
vars := []string{"username", "myAccountURL"}
|
|
||||||
vals := map[string]any{
|
|
||||||
"username": "TemplateUsername",
|
|
||||||
"myAccountURL": "TemplateURL",
|
|
||||||
}
|
|
||||||
|
|
||||||
target := strings.ReplaceAll(in, "{{username}}", vals["username"].(string))
|
|
||||||
target = strings.ReplaceAll(target, "{username}", vals["username"].(string))
|
|
||||||
target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string))
|
|
||||||
|
|
||||||
out, err := templateEmail(in, vars, []string{}, vals)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("no error when given double-braced variable")
|
|
||||||
}
|
|
||||||
|
|
||||||
if out != target {
|
|
||||||
t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVarAtAnyPosition(t *testing.T) {
|
|
||||||
in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.`
|
|
||||||
vars := []string{"username", "myAccountURL"}
|
|
||||||
vals := map[string]any{
|
|
||||||
"username": "TemplateUsername",
|
|
||||||
"myAccountURL": "TemplateURL",
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range in {
|
|
||||||
newIn := in[0:i] + "{" + vars[0] + "}" + in[i:]
|
|
||||||
|
|
||||||
target := strings.ReplaceAll(newIn, "{"+vars[0]+"}", vals["username"].(string))
|
|
||||||
|
|
||||||
out, err := templateEmail(newIn, vars, []string{}, vals)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error: %+v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out != target {
|
|
||||||
t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v, from "%+v""`, out, target, newIn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncompleteBlock(t *testing.T) {
|
|
||||||
in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.`
|
|
||||||
for i := range in {
|
|
||||||
newIn := in[0:i] + "{" + in[i:]
|
|
||||||
|
|
||||||
out, err := templateEmail(newIn, []string{"a"}, []string{"a"}, map[string]any{"a": "a"})
|
|
||||||
|
|
||||||
if out != newIn {
|
|
||||||
t.Fatalf(`returned string for position %d/%d doesn't match desired output: "%+v" != "%+v"`, i+1, len(newIn), out, newIn)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("no error when given incomplete block with brace at position %d/%d", i+1, len(newIn))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
views.go
12
views.go
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/hrfee/jfa-go/common"
|
"github.com/hrfee/jfa-go/common"
|
||||||
lm "github.com/hrfee/jfa-go/logmessages"
|
lm "github.com/hrfee/jfa-go/logmessages"
|
||||||
"github.com/hrfee/mediabrowser"
|
"github.com/hrfee/mediabrowser"
|
||||||
|
sTemplate "github.com/hrfee/simple-template"
|
||||||
"github.com/lithammer/shortuuid/v3"
|
"github.com/lithammer/shortuuid/v3"
|
||||||
"github.com/steambap/captcha"
|
"github.com/steambap/captcha"
|
||||||
)
|
)
|
||||||
@@ -812,13 +813,10 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
|
|||||||
data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
|
data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
|
||||||
}
|
}
|
||||||
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
|
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
|
||||||
cci := customContent["PostSignupCard"]
|
|
||||||
data["customSuccessCard"] = true
|
data["customSuccessCard"] = true
|
||||||
// We don't template here, since the username is only known after login.
|
// We don't template here, since the username is only known after login.
|
||||||
templated, err := templateEmail(
|
templated, err := sTemplate.Template(
|
||||||
msg.Content,
|
msg.Content,
|
||||||
cci.Variables,
|
|
||||||
cci.Conditionals,
|
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"username": "{username}", // Value is subbed by webpage
|
"username": "{username}", // Value is subbed by webpage
|
||||||
"myAccountURL": userPageAddress,
|
"myAccountURL": userPageAddress,
|
||||||
@@ -832,15 +830,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if msg, ok := app.storage.GetCustomContentKey("PreSignupCard"); ok && msg.Enabled {
|
if msg, ok := app.storage.GetCustomContentKey("PreSignupCard"); ok && msg.Enabled {
|
||||||
cci := customContent["PreSignupCard"]
|
|
||||||
data["preSignupCard"] = true
|
data["preSignupCard"] = true
|
||||||
// We don't template here, since the username is only known after login.
|
// We don't template here, since the username is only known after login.
|
||||||
templated, err := templateEmail(
|
templated, err := sTemplate.Template(
|
||||||
msg.Content,
|
msg.Content,
|
||||||
cci.Variables,
|
|
||||||
cci.Conditionals,
|
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"myAccountURL": userPageAddress,
|
"myAccountURL": userPageAddress,
|
||||||
|
"profile": invite.Profile,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user