chore: migrate mail themes to shared framework and locked upstream inputs
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 18:34:36 +08:00
1 parent 5c0589f6f6
commit fec3ace600
225 files changed
+4718 -15807

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
package builder
import (
"bytes"
"fmt"
"golang.org/x/net/html"
"io"
)
func BodyAnchors(masked []byte) ([2]int, error) {
var anchors [2]int
opens, closes, offset := 0, 0, 0
z := html.NewTokenizer(bytes.NewReader(masked))
for {
kind := z.Next()
raw := z.Raw()
start := offset
offset += len(raw)
switch kind {
case html.ErrorToken:
if z.Err() != io.EOF {
return anchors, z.Err()
}
if opens != 1 || closes != 1 || anchors[0] >= anchors[1] {
return anchors, fmt.Errorf("expected exactly one static body start and end")
}
return anchors, nil
case html.StartTagToken:
tag, _ := z.TagName()
if bytes.Equal(tag, []byte("body")) {
opens++
anchors[0] = offset
}
case html.EndTagToken:
tag, _ := z.TagName()
if bytes.Equal(tag, []byte("body")) {
closes++
anchors[1] = start
}
}
}
}
+357
View File
@@ -0,0 +1,357 @@
package builder
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gitea-mail-templates/tools/upstream"
)
type Package struct {
Theme *Theme
Files map[string][]byte // only custom overrides required by this theme
}
func BuildAll(dir string, s *upstream.Snapshot, filter map[string]bool) (map[string]*Package, error) {
names, err := Discover(dir)
if err != nil {
return nil, err
}
known := map[string]bool{}
for _, n := range names {
known[n] = true
}
for n := range filter {
if !known[n] {
return nil, fmt.Errorf("unknown theme %s", n)
}
}
result := map[string]*Package{}
for _, n := range names {
if len(filter) > 0 && !filter[n] {
continue
}
t, err := LoadTheme(filepath.Join(dir, n))
if err != nil {
return nil, fmt.Errorf("theme %s: %w", n, err)
}
p, err := Build(s, t)
if err != nil {
return nil, fmt.Errorf("theme %s: %w", n, err)
}
result[n] = p
}
return result, nil
}
func injection(name, content string) string {
return "<!-- THEME:" + name + " -->" + content + "<!-- /THEME:" + name + " -->"
}
func Build(s *upstream.Snapshot, t *Theme) (*Package, error) {
if err := ValidateCSS(t.CSS); err != nil {
return nil, err
}
p := &Package{Theme: t, Files: map[string][]byte{}}
head := s.Templates["mail/base/head.tmpl"]
p.Files["mail/base/head.tmpl"] = append(bytes.Clone(head), []byte(injection("HEAD", "\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<style>\n"+t.CSS+"\n</style>\n"))...)
p.Files["mail/base/footer.tmpl"] = bytes.Clone(s.Templates["mail/base/footer.tmpl"])
if t.Mode == "framed" {
if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil {
return nil, err
}
for name, b := range t.Controls {
p.Files[name] = bytes.Clone(b)
}
for _, name := range s.Entrypoints() {
open := strings.ReplaceAll(t.Open, "__MAIL_TYPE__", upstream.TemplateID(name))
open = strings.ReplaceAll(open, "__HEADER__", fmt.Sprintf(`{{template "mail/base/header" (dict "Variant" %q)}}`, t.Layout))
middle := strings.ReplaceAll(t.Footer, "__SIDEBAR__", `{{template "mail/base/sidebar" .}}`)
adapted, err := AdaptOfficial(s, name)
if err != nil {
return nil, err
}
content, err := frameWithFooter(adapted, open, middle, t.Close)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
p.Files[name] = content
}
} else if t.Mode != "shared" {
return nil, fmt.Errorf("unsupported theme mode %s", t.Mode)
}
for name, b := range p.Files {
if _, ok := t.Controls[name]; ok && t.Mode == "framed" {
continue
}
stripped, err := StripInjections(b)
if err != nil {
return nil, err
}
baseline := s.Templates[name]
if t.Mode == "framed" && !strings.HasPrefix(name, "mail/base/") {
baseline, err = AdaptOfficial(s, name)
if err != nil {
return nil, err
}
}
if !bytes.Equal(stripped, baseline) {
return nil, fmt.Errorf("generation changed official source: %s", name)
}
}
checked := *s
checked.Templates = EffectiveTemplates(s, p)
if err := upstream.ValidateKeys(&checked); err != nil {
return nil, fmt.Errorf("framework translations: %w", err)
}
return p, nil
}
// MaskActions keeps byte offsets stable and understands quotes, raw strings and
// Go template comments. HTML anchors inside actions must never be transformed.
func MaskActions(data []byte) ([]byte, error) {
masked, _, err := maskAndActions(data)
return masked, err
}
type actionSpan struct{ start, end int }
func maskAndActions(data []byte) ([]byte, []actionSpan, error) {
masked := bytes.Clone(data)
var actions []actionSpan
for i := 0; i < len(data)-1; i++ {
if data[i] != '{' || data[i+1] != '{' {
continue
}
start := i
i += 2
var quote byte
comment := false
closed := false
for i < len(data)-1 {
if comment {
if data[i] == '*' && data[i+1] == '/' {
comment = false
i += 2
} else {
i++
}
continue
}
if quote != 0 {
if data[i] == '\\' && quote != '`' {
i += 2
continue
}
if data[i] == quote {
quote = 0
}
i++
continue
}
if data[i] == '/' && data[i+1] == '*' {
comment = true
i += 2
continue
}
if data[i] == '"' || data[i] == '\'' || data[i] == '`' {
quote = data[i]
i++
continue
}
if data[i] == '}' && data[i+1] == '}' {
i += 2
closed = true
break
}
i++
}
if !closed {
return nil, nil, fmt.Errorf("unterminated Go template action")
}
for j := start; j < i; j++ {
if masked[j] != '\n' && masked[j] != '\r' {
masked[j] = ' '
}
}
actions = append(actions, actionSpan{start, i})
i--
}
return masked, actions, nil
}
func Frame(data []byte, open, close string) ([]byte, error) {
return frameWithFooter(data, open, "", close)
}
func frameWithFooter(data []byte, open, middle, close string) ([]byte, error) {
masked, actions, err := maskAndActions(data)
if err != nil {
return nil, err
}
// Tokenize only static HTML so fake anchors inside comments, attributes and
// style blocks do not become insertion points.
anchors, err := BodyAnchors(masked)
if err != nil {
return nil, err
}
start, end := anchors[0], anchors[1]
footer := []byte(`{{template "mail/base/footer"}}`)
footerAt, count := -1, 0
for _, a := range actions {
if bytes.Equal(data[a.start:a.end], footer) {
footerAt = a.start
count++
}
}
if count != 1 {
return nil, fmt.Errorf("expected one reviewed official footer call")
}
if footerAt < start || footerAt > end {
return nil, fmt.Errorf("official footer is outside body")
}
result := append([]byte{}, data[:start]...)
result = append(result, []byte(injection("OPEN", open))...)
result = append(result, data[start:footerAt]...)
if middle != "" {
result = append(result, []byte(injection("FOOTER", middle))...)
}
result = append(result, data[footerAt:end]...)
result = append(result, []byte(injection("CLOSE", close))...)
result = append(result, data[end:]...)
return result, nil
}
var injectionPattern = regexp.MustCompile(`(?s)<!-- THEME:(HEAD|OPEN|FOOTER|CLOSE) -->.*?<!-- /THEME:(HEAD|OPEN|FOOTER|CLOSE) -->`)
func StripInjections(data []byte) ([]byte, error) {
for _, m := range injectionPattern.FindAllSubmatch(data, -1) {
if !bytes.Equal(m[1], m[2]) {
return nil, fmt.Errorf("mismatched injection markers")
}
}
result := injectionPattern.ReplaceAll(data, nil)
if bytes.Contains(result, []byte("<!-- THEME:")) || bytes.Contains(result, []byte("<!-- /THEME:")) {
return nil, fmt.Errorf("unpaired injection marker")
}
return result, nil
}
// EffectiveTemplates overlays the minimal install package on stock Gitea,
// matching the custom-before-builtin lookup used by Gitea's layered asset FS.
func EffectiveTemplates(s *upstream.Snapshot, p *Package) map[string][]byte {
result := map[string][]byte{}
for n, b := range s.Templates {
result[n] = b
}
if p != nil {
for n, b := range p.Files {
result[n] = b
}
}
return result
}
// Write owns only generated files under build/themes. Updating files in place
// avoids directory-rename races with Windows antivirus and the dev watcher.
func Write(output string, s *upstream.Snapshot, packages map[string]*Package) error {
abs, err := filepath.Abs(output)
if err != nil {
return err
}
if filepath.Base(abs) != "themes" || filepath.Base(filepath.Dir(abs)) != "build" {
return fmt.Errorf("generated output must be build/themes")
}
if err := os.MkdirAll(abs, 0755); err != nil {
return err
}
var names []string
for n := range packages {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
if !ValidName(name) {
return fmt.Errorf("unsafe theme name")
}
dest := filepath.Join(abs, name)
if info, err := os.Lstat(dest); err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("generated theme cannot be a symlink")
}
if _, err := os.Stat(dest); err == nil {
if _, err := os.Stat(filepath.Join(dest, "build.json")); err != nil {
return fmt.Errorf("refusing to replace non-generated directory %s", dest)
}
}
p := packages[name]
if err := os.MkdirAll(dest, 0755); err != nil {
return err
}
err := filepath.WalkDir(dest, func(filename string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("generated symlink %s", filename)
}
if !d.IsDir() && strings.HasSuffix(filename, ".tmpl") {
rel, _ := filepath.Rel(dest, filename)
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "mail/") || !upstream.SafePath(rel) {
return fmt.Errorf("unexpected generated file %s", rel)
}
if _, ok := p.Files[rel]; !ok {
return os.Remove(filename)
}
}
return nil
})
if err != nil {
return err
}
hashes := map[string]string{}
for filename, b := range p.Files {
if !upstream.SafePath(filename) {
return fmt.Errorf("unsafe generated path")
}
target := filepath.Join(dest, filepath.FromSlash(filename))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
if err := os.WriteFile(target, b, 0644); err != nil {
return err
}
hashes[filename] = upstream.Digest(b)
}
meta, _ := json.MarshalIndent(map[string]any{"theme": name, "mode": p.Theme.Mode, "gitea_tag": s.Lock.Tag, "gitea_commit": s.Lock.Commit, "files": hashes, "sources": p.Theme.Sources}, "", " ")
if err := os.WriteFile(filepath.Join(dest, "build.json"), append(meta, '\n'), 0644); err != nil {
return err
}
}
return nil
}
func Filter(names []string) (map[string]bool, error) {
if len(names) == 0 {
return nil, fmt.Errorf("specify themes or 'all'")
}
if len(names) == 1 && names[0] == "all" {
return nil, nil
}
f := map[string]bool{}
for _, n := range names {
if !ValidName(n) {
return nil, fmt.Errorf("invalid theme %q", n)
}
f[n] = true
}
return f, nil
}
func TemplateName(p string) string { return strings.TrimSuffix(p, ".tmpl") }
+106
View File
@@ -0,0 +1,106 @@
package builder
import (
"bytes"
"gitea-mail-templates/tools/upstream"
"os"
"path/filepath"
"testing"
)
func TestGenerationPreservesSnapshot(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
packages, err := BuildAll(filepath.Join("..", "..", "themes"), s, nil)
if err != nil {
t.Fatal(err)
}
for name, p := range packages {
if len(p.Files) != len(s.Templates)+3 {
t.Fatalf("%s missing generated files", name)
}
for filename, generated := range p.Files {
if _, ok := p.Theme.Controls[filename]; ok {
continue
}
original, err := StripInjections(generated)
if err != nil {
t.Fatal(err)
}
baseline := s.Templates[filename]
if !bytes.HasPrefix([]byte(filename), []byte("mail/base/")) {
baseline, err = AdaptOfficial(s, filename)
if err != nil {
t.Fatal(err)
}
}
if !bytes.Equal(original, baseline) {
t.Fatal("official source altered")
}
}
again, err := Build(s, p.Theme)
if err != nil {
t.Fatal(err)
}
for filename, b := range again.Files {
if !bytes.Equal(b, p.Files[filename]) {
t.Fatal("nondeterministic build")
}
}
}
shared, err := Build(s, &Theme{Name: "test", Mode: "shared", CSS: "a {color:#000}"})
if err != nil {
t.Fatal(err)
}
if len(shared.Files) != 2 {
t.Fatal("shared theme overrides正文")
}
output := filepath.Join(t.TempDir(), "build", "themes")
if err := Write(output, s, map[string]*Package{"test": packages["mono"]}); err != nil {
t.Fatal(err)
}
if err := Write(output, s, map[string]*Package{"test": shared}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(output, "test", "mail", "repo", "release.tmpl")); !os.IsNotExist(err) {
t.Fatal("stale framed override retained")
}
}
func TestRejectUnsafeDecorationAndAnchors(t *testing.T) {
for _, markup := range []string{"<div>Extra text</div>", "<a href='https://example.com'></a>", "<div onclick='bad()'></div>", "<div>{{.Subject}}</div>", "<table><tr></table>"} {
if ValidateDecoration(markup) == nil {
t.Fatalf("accepted %s", markup)
}
}
for _, css := range []string{"a {background:url(https://example.com)}", "p {display:none}", "div:before {content:'Extra'}", "</style>"} {
if ValidateCSS(css) == nil {
t.Fatalf("accepted %s", css)
}
}
if err := ValidateCSS("p {font-size:0.9em}"); err != nil {
t.Fatal(err)
}
if ValidateCSS("p {font-size:0!important}") == nil {
t.Fatal("accepted hidden text")
}
for _, source := range []string{"<html></html>", "<body><body>{{template \"mail/base/footer\"}}</body>", "<body></body>", "<body>{{template \"mail/base/footer\"}}</body></body>"} {
if _, err := Frame([]byte(source), "<div>", "</div>"); err == nil {
t.Fatalf("accepted changed anchor %s", source)
}
}
good := []byte("<!-- <body> -->{{ $x := \"<body> }}\" }}<body>{{template \"mail/base/footer\"}}</body>")
result, err := Frame(good, "<div>", "</div>")
if err != nil {
t.Fatal(err)
}
stripped, err := StripInjections(result)
if err != nil || !bytes.Equal(stripped, good) {
t.Fatal("action/comment anchors altered")
}
if _, err := MaskActions([]byte("{{ /* unclosed")); err == nil {
t.Fatal("accepted invalid action")
}
}
+139
View File
@@ -0,0 +1,139 @@
package builder
import (
"bytes"
"fmt"
"gitea-mail-templates/tools/upstream"
"regexp"
"strings"
)
type actionSpec struct {
expression, href, text, key string
args string
}
// This is the single alignment layer between reviewed official mail contexts
// and shared controls. Themes never own URLs, translation keys or mail logic.
var actions = map[string]actionSpec{
"activate": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_account"},
"activate_email": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_email"},
"reset_passwd": {expression: "$recover_url", href: "{{$recover_url}}", text: "{{$recover_url}}", key: "mail.reset_password"},
"register_notify": {expression: `(printf "%suser/login" AppUrl)`, href: "{{AppUrl}}user/login", text: "{{AppUrl}}user/login", key: "mail.view_it_on", args: " AppName"},
"team_invite": {expression: ".InviteURL", href: "{{.InviteURL}}", text: "{{.InviteURL}}", key: "mail.view_it_on", args: " AppName"},
"collaborator": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
"transfer": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
"release": {expression: ".Release.HTMLURL", key: "mail.view_it_on", args: " AppName"},
"workflow_run": {expression: ".Run.HTMLURL", key: "mail.view_it_on", args: " AppName"},
"assigned": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
"default": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
}
type sourceEdit struct{ before, after string }
// AdaptOfficial leaves notification branches/data accesses upstream-owned;
// only the presentation of the primary action and fallback is reorganized.
// An edit ledger checks action changes before static presentation annotation.
func AdaptOfficial(s *upstream.Snapshot, name string) ([]byte, error) {
original := string(s.Templates[name])
source := original
spec, ok := actions[upstream.TemplateID(name)]
if !ok {
return nil, fmt.Errorf("new mail type %s needs framework alignment", name)
}
if _, ok := s.Locales["en-US"][spec.key]; !ok {
return nil, fmt.Errorf("missing framework translation key %s", spec.key)
}
control := fmt.Sprintf(`{{template "mail/base/action" (dict "URL" %s "Label" (.locale.Tr %q%s) "Hint" (.locale.Tr "mail.link_not_working_do_paste"))}}`, spec.expression, spec.key, spec.args)
var edits []sourceEdit
replace := func(before, after string) error {
if strings.Count(source, before) != 1 {
return fmt.Errorf("%s: expected one reviewed action anchor %q", name, before)
}
source = strings.Replace(source, before, after, 1)
edits = append(edits, sourceEdit{before, after})
return nil
}
if spec.href != "" {
// Search only static markup/action boundaries reviewed for the primary URL.
pattern := regexp.MustCompile(`<p(?: style="word-break: break-all;")?><a href="` + regexp.QuoteMeta(spec.href) + `">` + regexp.QuoteMeta(spec.text) + `</a></p>`)
matches := pattern.FindAllString(source, -1)
if len(matches) != 1 {
return nil, fmt.Errorf("%s: primary URL anchor changed; review framework adapter", name)
}
if err := replace(matches[0], control); err != nil {
return nil, err
}
hint := `<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>`
if strings.Contains(source, hint) {
if err := replace(hint, ""); err != nil {
return nil, err
}
}
} else {
// These official bodies already contain their primary link in prose.
// The extra button/fallback does not replace notification text or links.
footer := `{{template "mail/base/footer"}}`
if err := replace(footer, `<div class="framework-extra">`+control+`</div>`+"\n"+footer); err != nil {
return nil, err
}
}
// Replay the recorded action edits and require an exact output match.
replay := original
for _, e := range edits {
replay = strings.Replace(replay, e.before, e.after, 1)
}
if replay != source {
return nil, fmt.Errorf("unrecorded framework change")
}
if !bytes.Equal([]byte(original), s.Templates[name]) {
return nil, fmt.Errorf("upstream source mutated")
}
return paragraphRoles([]byte(source), upstream.TemplateID(name))
}
// Use the established theme classes rather than duplicating their typography
// in generic CSS. Only static paragraph tags outside Go actions are annotated.
func paragraphRoles(data []byte, id string) ([]byte, error) {
masked, err := MaskActions(data)
if err != nil {
return nil, err
}
anchors, err := BodyAnchors(masked)
if err != nil {
return nil, err
}
tags := regexp.MustCompile(`(?i)<p(?:\s[^<>]*?)?>`).FindAllIndex(masked[anchors[0]:anchors[1]], -1)
type edit struct {
at int
class string
}
var edits []edit
for i, loc := range tags {
start, end := anchors[0]+loc[0], anchors[0]+loc[1]
if bytes.Contains(masked[start:end], []byte("class=")) {
continue
}
role := "email-text"
if i == 0 && id != "activate" && id != "activate_email" {
role = "email-heading"
}
stop := bytes.Index(masked[end:anchors[1]], []byte("</p>"))
if stop < 0 {
return nil, fmt.Errorf("unclosed official paragraph")
}
content := string(data[end : end+stop])
for _, key := range []string{"mail.activate_account.text_2", "mail.register_notify.text_2", "mail.register_notify.text_3", "mail.team_invite.text_3"} {
if strings.Contains(content, `"`+key+`"`) {
role = "email-subtext"
}
}
edits = append(edits, edit{start + 2, role})
}
result := bytes.Clone(data)
for i := len(edits) - 1; i >= 0; i-- {
e := edits[i]
result = append(append(append([]byte{}, result[:e.at]...), []byte(` class="`+e.class+`"`)...), result[e.at:]...)
}
return result, nil
}
+40
View File
@@ -0,0 +1,40 @@
package builder
import (
"bytes"
"gitea-mail-templates/tools/upstream"
"path/filepath"
"strings"
"testing"
)
func TestParagraphRolesPreserveOfficialActions(t *testing.T) {
source := `<html><body><p>{{.locale.Tr "mail.hi_user_x" .Username}}</p><p>{{.locale.Tr "mail.register_notify.text_2"}}</p>{{printf "<p>not static</p>"}}<p class="existing">Kept</p></body></html>`
result, err := paragraphRoles([]byte(source), "register_notify")
if err != nil {
t.Fatal(err)
}
want := strings.Replace(source, `<p>`, `<p class="email-heading">`, 1)
want = strings.Replace(want, `<p>`, `<p class="email-subtext">`, 1)
if string(result) != want {
t.Fatalf("unexpected presentation annotation: %s", result)
}
}
func TestSharedAlignmentFailsOnDrift(t *testing.T) {
s, err := upstream.LoadPinned(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
name := "mail/user/auth/activate.tmpl"
original := bytes.Clone(s.Templates[name])
s.Templates[name] = bytes.Replace(original, []byte(`href="{{$activate_url}}"`), []byte(`href="changed"`), 1)
if _, err := AdaptOfficial(s, name); err == nil {
t.Fatal("unreviewed URL anchor accepted")
}
s.Templates[name] = original
delete(s.Locales["en-US"], "mail.activate_account")
if _, err := AdaptOfficial(s, name); err == nil {
t.Fatal("missing button key accepted")
}
}
+258
View File
@@ -0,0 +1,258 @@
// Package builder generates Gitea overrides from immutable upstream templates.
package builder
import (
"encoding/json"
"fmt"
"gitea-mail-templates/tools/upstream"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"golang.org/x/net/html"
)
type Theme struct {
Name string `json:"name"`
Description string `json:"description"`
Mode string `json:"mode"`
Layout string `json:"layout,omitempty"`
CSS string `json:"-"`
Open string `json:"-"`
Close string `json:"-"`
Footer string `json:"-"`
Sources map[string]string `json:"-"`
Controls map[string][]byte `json:"-"`
}
var themeName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
var cssBlocks = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`)
var hiddenContent = regexp.MustCompile(`(?:display\s*:\s*none\b|visibility\s*:\s*hidden\b|font-size\s*:\s*0(?:px)?\s*(?:!important\s*)?(?:;|$))`)
func ValidName(name string) bool { return themeName.MatchString(name) }
func Discover(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.Type()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("theme symlinks are unsupported: %s", e.Name())
}
if !e.IsDir() {
continue
}
if !ValidName(e.Name()) {
return nil, fmt.Errorf("invalid theme name %q", e.Name())
}
if _, err := os.Stat(filepath.Join(dir, e.Name(), "theme.json")); err != nil {
return nil, fmt.Errorf("theme %s: %w", e.Name(), err)
}
names = append(names, e.Name())
}
if len(names) == 0 {
return nil, fmt.Errorf("no theme manifests in %s", dir)
}
sort.Strings(names)
return names, nil
}
func LoadTheme(dir string) (*Theme, error) {
b, err := os.ReadFile(filepath.Join(dir, "theme.json"))
if err != nil {
return nil, err
}
t := &Theme{}
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(t); err != nil {
return nil, err
}
if !ValidName(t.Name) || t.Name != filepath.Base(dir) || (t.Mode != "shared" && t.Mode != "framed") {
return nil, fmt.Errorf("invalid theme name or mode in %s", dir)
}
b, err = os.ReadFile(filepath.Join(dir, "theme.css"))
if err != nil {
return nil, err
}
t.CSS = string(b)
if err := ValidateCSS(t.CSS); err != nil {
return nil, err
}
if t.Mode == "framed" {
if t.Layout == "" {
t.Layout = "standard"
}
if !ValidName(t.Layout) {
return nil, fmt.Errorf("invalid framework layout %q", t.Layout)
}
root := filepath.Dir(filepath.Dir(dir))
layoutDir := filepath.Join(root, "framework", "layouts", t.Layout)
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-open.html"))
if err != nil {
return nil, err
}
t.Open = string(b)
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-close.html"))
if err != nil {
return nil, err
}
t.Close = string(b)
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-footer.html"))
if err == nil {
t.Footer = string(b)
} else if !os.IsNotExist(err) {
return nil, err
}
if strings.Count(t.Open, "__HEADER__") != 1 {
return nil, fmt.Errorf("layout must contain one header slot")
}
if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil {
return nil, err
}
t.Controls = map[string][]byte{}
for _, name := range []string{"header", "footer", "action", "sidebar"} {
b, err := os.ReadFile(filepath.Join(root, "framework", "mail", "base", name+".tmpl"))
if err != nil {
return nil, err
}
t.Controls["mail/base/"+name+".tmpl"] = b
}
}
t.Sources = map[string]string{}
for _, name := range []string{"theme.json", "theme.css"} {
filename := filepath.Join(dir, name)
info, err := os.Lstat(filename)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("theme resource must be a regular file: %s", filename)
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
t.Sources[name] = upstream.Digest(data)
}
if t.Mode == "framed" {
root := filepath.Dir(filepath.Dir(dir))
for name, b := range t.Controls {
t.Sources["framework/"+name] = upstream.Digest(b)
}
for _, name := range []string{"frame-open.html", "frame-footer.html", "frame-close.html"} {
path := filepath.Join("framework", "layouts", t.Layout, name)
b, err := os.ReadFile(filepath.Join(root, path))
if os.IsNotExist(err) && name == "frame-footer.html" {
continue
}
if err != nil {
return nil, err
}
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
}
for _, name := range []string{"build.go", "framework.go", "theme.go", "anchors.go"} {
path := filepath.Join("tools", "builder", name)
b, err := os.ReadFile(filepath.Join(root, path))
if err != nil {
return nil, err
}
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
}
}
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(dir, path)
if rel != "theme.json" && rel != "theme.css" {
return fmt.Errorf("theme %s may contain only theme.json and theme.css, not %s", t.Name, rel)
}
return nil
}); err != nil {
return nil, err
}
return t, nil
}
func ValidateCSS(css string) error {
low := strings.ToLower(css)
for _, bad := range []string{"{{", "}}", "<", "url(", "@import", "expression(", "javascript:", "content:", "content :"} {
if strings.Contains(low, bad) {
return fmt.Errorf("theme CSS contains disallowed content %q", bad)
}
}
for _, block := range cssBlocks.FindAllStringSubmatch(low, -1) {
if hiddenContent.MatchString(block[2]) {
for _, selector := range strings.Split(block[1], ",") {
selector = strings.TrimSpace(selector)
if selector != ".resp-hide" && selector != ".email-sidebar" && selector != ".email-ornament" {
return fmt.Errorf("CSS may hide only decorative nodes, not %s", selector)
}
}
}
}
return nil
}
// Decoration may contain structural presentation nodes but no text, business
// links, template actions, scripts, images, or external resources.
func ValidateDecoration(fragment string) error {
if strings.Contains(fragment, "{{") || strings.Contains(fragment, "}}") {
return fmt.Errorf("decoration must not contain Go template actions")
}
allowed := map[string]bool{"table": true, "tbody": true, "tr": true, "td": true, "div": true, "span": true}
attrs := map[string]bool{"class": true, "style": true, "role": true, "width": true, "height": true, "cellpadding": true, "cellspacing": true, "border": true, "align": true, "valign": true, "aria-hidden": true, "colspan": true}
z := html.NewTokenizer(strings.NewReader(fragment))
var stack []string
for {
switch z.Next() {
case html.ErrorToken:
if z.Err() != io.EOF {
return z.Err()
}
if len(stack) != 0 {
return fmt.Errorf("unbalanced decoration")
}
return nil
case html.TextToken:
if strings.TrimSpace(string(z.Text())) != "" {
return fmt.Errorf("decoration must not add visible text")
}
case html.StartTagToken, html.SelfClosingTagToken:
token := z.Token()
if !allowed[token.Data] {
return fmt.Errorf("disallowed decoration element %s", token.Data)
}
for _, a := range token.Attr {
if !attrs[a.Key] {
return fmt.Errorf("disallowed decoration attribute %s", a.Key)
}
if a.Key == "style" {
if err := ValidateCSS(a.Val); err != nil {
return err
}
}
}
if token.Type == html.StartTagToken {
stack = append(stack, token.Data)
}
case html.EndTagToken:
token := z.Token()
if len(stack) == 0 || stack[len(stack)-1] != token.Data {
return fmt.Errorf("unbalanced decoration end %s", token.Data)
}
stack = stack[:len(stack)-1]
default:
return fmt.Errorf("decoration accepts only static presentation markup")
}
}
}