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")
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package cli
import (
"fmt"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/upstream"
"github.com/urfave/cli/v2"
"path/filepath"
)
func BuildCommand() *cli.Command {
return &cli.Command{Name: "build", Usage: "Generate installable themes from locked official inputs and shared controls", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: func(c *cli.Context) error {
s, err := upstream.Prepare(c.Context, filepath.Dir(c.String("folder")))
if err != nil {
return err
}
filter, err := builder.Filter(c.Args().Slice())
if err != nil {
return err
}
packages, err := builder.BuildAll(c.String("folder"), s, filter)
if err != nil {
return err
}
output := filepath.Join(filepath.Dir(c.String("folder")), "build", "themes")
if err := builder.Write(output, s, packages); err != nil {
return err
}
fmt.Printf("[PASS] Built %d themes for %s at %s\n", len(packages), s.Lock.Tag, output)
return nil
}}
}
+23 -67
View File
@@ -1,86 +1,42 @@
package cli
import (
"encoding/json"
"fmt"
"gitea-mail-templates/tools/builder"
"github.com/urfave/cli/v2"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
)
// CreateCommand returns the "create" subcommand.
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create one or more new theme style directories",
UsageText: "go run . create [--folder <themes-dir>] [--config <config-file>] <style-name>...",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
&cli.StringFlag{
Name: "config",
Value: "./data/templates_config.json",
Usage: "Path to templates_config.json",
},
},
Action: runCreate,
}
return &cli.Command{Name: "create", Usage: "Create CSS and metadata using the shared control framework", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: runCreate}
}
func runCreate(c *cli.Context) error {
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("at least one style name is required")
return fmt.Errorf("at least one theme name is required")
}
folder := c.String("folder")
cfg, err := config.Load(c.String("config"))
if err != nil {
return err
}
for _, styleName := range names {
styleDir := filepath.Join(folder, styleName)
if info, err := os.Stat(styleDir); err == nil {
if info.IsDir() {
fmt.Printf("\033[32m[I]\033[0m [CLI] '%s' already exists, skipped\n", styleName)
continue
}
return fmt.Errorf("'%s' exists but is not a directory", styleName)
for _, name := range names {
if !builder.ValidName(name) {
return fmt.Errorf("invalid theme name %q", name)
}
created := 0
for _, tplCfg := range cfg.Templates {
tmplPath := filepath.Join(styleDir, tplCfg.PathStr())
dir := filepath.Dir(tmplPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create directory %s: %w", dir, err)
}
placeholder := fmt.Sprintf("<!DOCTYPE html>\n<html>\n<head>\n"+
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n"+
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n"+
" <title>{{.locale.Tr \"mail.%s.title\"}}</title>\n"+
" <style type=\"text/css\">\n /* TODO: Add your custom styles here */\n"+
" </style>\n</head>\n<body style=\"margin:0;padding:0;\">\n"+
" <!-- TODO: Add your custom template design for: %s -->\n"+
"</body>\n</html>\n", tplCfg.Name, tplCfg.Desc)
if err := os.WriteFile(tmplPath, []byte(placeholder), 0644); err != nil {
return fmt.Errorf("cannot write %s: %w", tmplPath, err)
}
created++
dir := filepath.Join(c.String("folder"), name)
if _, err := os.Lstat(dir); err == nil {
return fmt.Errorf("theme %s already exists", name)
}
fmt.Printf("\033[32m[I]\033[0m [CLI] Created style '%s' with %d template files\n", styleName, created)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
meta, _ := json.MarshalIndent(builder.Theme{Name: name, Description: "New theme", Mode: "framed", Layout: "standard"}, "", " ")
if err := os.WriteFile(filepath.Join(dir, "theme.json"), append(meta, '\n'), 0644); err != nil {
return err
}
css := "/* Presentation only; shared controls own mail data and translations. */\nbody { margin:0; padding:24px 16px; background:#f4f6f9; }\nbody, td { font-family:Arial, sans-serif; }\n.email-wrap { max-width:600px; margin:auto; background:#fff; }\n.email-header, .email-card, .email-footer { padding:24px; }\n.email-logo { vertical-align:middle; margin-right:8px; }\n.email-btn,.card-body .email-btn,.card-body .email-btn:visited { display:inline-block; padding:12px 24px; color:#fff!important; background:#2563eb; text-decoration:none; border-radius:6px; }\n.email-link-url { overflow-wrap:anywhere; }\na, a:visited { color:#2563eb; }\n"
if err := os.WriteFile(filepath.Join(dir, "theme.css"), []byte(css), 0644); err != nil {
return err
}
fmt.Printf("[PASS] Created framework theme %s\n", name)
}
return nil
}
+10
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"gitea-mail-templates/tools/builder"
"github.com/urfave/cli/v2"
)
@@ -34,12 +35,21 @@ func runDelete(c *cli.Context) error {
folder := c.String("folder")
for _, styleName := range names {
if !builder.ValidName(styleName) {
return fmt.Errorf("invalid theme name %q", styleName)
}
styleDir := filepath.Join(folder, styleName)
if info, err := os.Lstat(styleDir); err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to delete a theme symlink")
}
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
fmt.Printf("\033[33m[W]\033[0m [CLI] '%s' does not exist, skipped\n", styleName)
continue
}
if _, err := builder.LoadTheme(styleDir); err != nil {
return fmt.Errorf("refusing to delete a non-theme directory: %w", err)
}
if err := os.RemoveAll(styleDir); err != nil {
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
+14 -46
View File
@@ -2,56 +2,24 @@ package cli
import (
"fmt"
"os"
"path/filepath"
"sort"
"gitea-mail-templates/tools/builder"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/preview"
"path/filepath"
)
// ListCommand returns the "list" subcommand.
func ListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List available theme styles in the target folder",
UsageText: "go run . list [--folder <themes-dir>]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
},
Action: runList,
}
}
func runList(c *cli.Context) error {
folder := c.String("folder")
themes, err := preview.DiscoverThemes(folder)
if err != nil {
return err
}
absDir, _ := filepath.Abs(folder)
fmt.Printf("\033[32m[I]\033[0m [Preview] Available styles in '%s' (%d):\n", filepath.Base(absDir), len(themes))
sort.Strings(themes)
for _, t := range themes {
count := 0
filepath.Walk(filepath.Join(folder, t), func(path string, info os.FileInfo, err error) error {
return &cli.Command{Name: "list", Usage: "List available theme manifests", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: func(c *cli.Context) error {
names, err := builder.Discover(c.String("folder"))
if err != nil {
return err
}
for _, name := range names {
t, err := builder.LoadTheme(filepath.Join(c.String("folder"), name))
if err != nil {
return nil
return err
}
if filepath.Ext(path) == ".tmpl" {
count++
}
return nil
})
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s (%d .tmpl files)\n", t, count)
}
return nil
fmt.Printf("%-16s %-8s %s\n", name, t.Mode, t.Description)
}
return nil
}}
}
+4 -4
View File
@@ -66,9 +66,9 @@ func runPreview(c *cli.Context) error {
}
}
result := preview.RenderAll(folder, cfg, themeFilter)
if result == nil {
return fmt.Errorf("no results produced")
result, err := preview.RenderAll(folder, cfg, themeFilter)
if err != nil {
return err
}
if err := preview.WriteRenderedJS(result, outputPath); err != nil {
@@ -78,7 +78,7 @@ func runPreview(c *cli.Context) error {
preview.PrintDetailedSummary(result, folder, cfg)
fmt.Printf("\033[32m[I]\033[0m [Preview] Wrote %d themes x %d templates to %s\n",
len(result.Summaries), len(cfg.Templates), outputPath)
len(result.Themes), len(cfg.Templates), outputPath)
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package cli
import (
"fmt"
"gitea-mail-templates/tools/upstream"
"github.com/urfave/cli/v2"
)
func UpstreamCommand() *cli.Command {
return &cli.Command{Name: "upstream", Usage: "Prepare, synchronize or verify locked official build inputs", Subcommands: []*cli.Command{
{Name: "prepare", Usage: "Download missing immutable cache files from gitea.lock.json", Flags: []cli.Flag{&cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error { _, err := upstream.Prepare(c.Context, c.String("root")); return err }},
{Name: "sync", Usage: "Review a new Gitea tag and regenerate the lock and cache", Flags: []cli.Flag{&cli.StringFlag{Name: "tag", Required: true}, &cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error { return upstream.SyncRoot(c.Context, c.String("tag"), c.String("root")) }},
{Name: "verify", Usage: "Verify cached inputs against the repository lock offline", Flags: []cli.Flag{&cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error {
s, err := upstream.LoadPinned(c.String("root"))
if err != nil {
return err
}
missing, err := upstream.MissingKeys(s)
if err != nil {
return err
}
fmt.Printf("[PASS] %s: %d mail files, %d languages\n", s.Lock.Tag, len(s.Templates), len(s.Locales))
for _, lang := range s.Languages() {
if keys := missing[lang]; len(keys) > 0 {
fmt.Printf("[FALLBACK] %s -> en-US: %v\n", lang, keys)
}
}
return nil
}},
}}
}
+31 -2
View File
@@ -12,7 +12,7 @@ import (
type TemplateConfig struct {
Name string `json:"name"`
Desc string `json:"desc"`
Path []string `json:"path"`
Path []string `json:"-"` // Discovered from the pinned upstream snapshot.
Params map[string]any `json:"params"`
}
@@ -29,7 +29,9 @@ func Load(path string) (*TemplatesConfig, error) {
}
var cfg TemplatesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.UseNumber()
if err := decoder.Decode(&cfg); err != nil {
return nil, fmt.Errorf("cannot parse config file %s: %w", path, err)
}
@@ -37,9 +39,36 @@ func Load(path string) (*TemplatesConfig, error) {
return nil, fmt.Errorf("config file %s contains no template definitions", path)
}
for id, t := range cfg.Templates {
t.Params = normalizeNumbers(t.Params).(map[string]any)
cfg.Templates[id] = t
}
return &cfg, nil
}
func normalizeNumbers(v any) any {
switch v := v.(type) {
case json.Number:
if n, err := v.Int64(); err == nil {
return n
}
n, _ := v.Float64()
return n
case map[string]any:
for k, value := range v {
v[k] = normalizeNumbers(value)
}
return v
case []any:
for i, value := range v {
v[i] = normalizeNumbers(value)
}
return v
default:
return v
}
}
// PathStr returns the file path of a template definition joined with OS separator.
func (t *TemplateConfig) PathStr() string {
return filepath.Join(t.Path...)
+4 -14
View File
@@ -3,7 +3,6 @@
"activate": {
"name": "Activate Account",
"desc": "Account activation — sent when a new user registers.",
"path": ["mail", "user", "auth", "activate.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "activation-code-abc123",
@@ -13,7 +12,6 @@
"activate_email": {
"name": "Activate Email",
"desc": "Email address verification — sent when adding or changing an email address.",
"path": ["mail", "user", "auth", "activate_email.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "verify-code-xyz789",
@@ -24,7 +22,6 @@
"register_notify": {
"name": "Register Notify",
"desc": "Registration notification — confirms successful account registration.",
"path": ["mail", "user", "auth", "register_notify.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Username": "alexj"
@@ -33,7 +30,6 @@
"reset_passwd": {
"name": "Reset Password",
"desc": "Password reset — sent when user requests a password reset link.",
"path": ["mail", "user", "auth", "reset_passwd.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "reset-token-abc123",
@@ -43,7 +39,6 @@
"team_invite": {
"name": "Team Invite",
"desc": "Team invitation — sent when a user is invited to join an organization team.",
"path": ["mail", "org", "team_invite.tmpl"],
"params": {
"Inviter": { "DisplayName": "Sarah Chen" },
"Team": { "Name": "Core Developers" },
@@ -55,7 +50,6 @@
"collaborator": {
"name": "Collaborator Added",
"desc": "Collaborator added — sent when a user is added as a repository collaborator.",
"path": ["mail", "repo", "collaborator.tmpl"],
"params": {
"Subject": "Collaborator Added to repo",
"Link": "https://gitea.example.com/repo/project",
@@ -65,7 +59,6 @@
"transfer": {
"name": "Repository Transfer",
"desc": "Repository transfer — sent when repository ownership is transferred.",
"path": ["mail", "repo", "transfer.tmpl"],
"params": {
"Subject": "Repository Transfer",
"Link": "https://gitea.example.com/repo/transferred-project",
@@ -75,7 +68,6 @@
"release": {
"name": "New Release",
"desc": "New release — sent when a new release is published.",
"path": ["mail", "repo", "release.tmpl"],
"params": {
"Subject": "New Release v2.0.0",
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
@@ -99,7 +91,6 @@
"workflow_run": {
"name": "Workflow Run",
"desc": "Workflow run — sent when a CI/CD workflow completes.",
"path": ["mail", "repo", "actions", "workflow_run.tmpl"],
"params": {
"Subject": "Workflow Run: CI",
"RunStatusText": "All jobs passed",
@@ -109,18 +100,18 @@
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42"
},
"Jobs": [
{ "Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
{ "Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
{ "Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
{ "Name": "build", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
{ "Name": "test", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
{ "Name": "deploy", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
]
}
},
"assigned": {
"name": "Issue Assigned",
"desc": "Issue/PR assigned — sent when an issue or pull request is assigned.",
"path": ["mail", "repo", "issue", "assigned.tmpl"],
"params": {
"Subject": "Issue #42 Assigned",
"CanReply": false,
"Link": "https://gitea.example.com/repo/project/issues/42",
"Doer": { "Name": "Sarah Chen" },
"IsPull": false,
@@ -136,7 +127,6 @@
"default": {
"name": "Issue Update",
"desc": "Issue/PR update — comments, mentions, reviews, merges, and other activity.",
"path": ["mail", "repo", "issue", "default.tmpl"],
"params": {
"Subject": "Re: Issue #42 -- Bug fix",
"Link": "https://gitea.example.com/repo/project/issues/42",
+5 -2
View File
@@ -1,8 +1,11 @@
module gitea-mail-templates/tools
go 1.21
go 1.24.0
require github.com/urfave/cli/v2 v2.27.7
require (
github.com/urfave/cli/v2 v2.27.7
golang.org/x/net v0.46.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
+2
View File
@@ -6,3 +6,5 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+315
View File
@@ -0,0 +1,315 @@
// Package integration exercises an explicitly supplied official Gitea binary.
// Everything runs on loopback with disposable users, SQLite and captured SMTP.
package integration
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/http/cookiejar"
"net/mail"
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/upstream"
)
type safeLog struct {
mu sync.Mutex
buf bytes.Buffer
}
func (l *safeLog) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
func (l *safeLog) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() }
func smtpCapture(t *testing.T) (int, <-chan []byte) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
mailbox := make(chan []byte, 4)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
conn.SetDeadline(time.Now().Add(time.Minute))
r := textproto.NewReader(bufio.NewReader(conn))
fmt.Fprint(conn, "220 localhost SMTP capture\r\n")
for {
line, err := r.ReadLine()
if err != nil {
return
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
command := strings.ToUpper(fields[0])
switch command {
case "EHLO", "HELO":
fmt.Fprint(conn, "250 localhost\r\n")
case "DATA":
fmt.Fprint(conn, "354 Send mail\r\n")
data, err := r.ReadDotBytes()
if err != nil {
return
}
mailbox <- data
fmt.Fprint(conn, "250 Captured\r\n")
case "QUIT":
fmt.Fprint(conn, "221 Goodbye\r\n")
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
}
}()
return listener.Addr().(*net.TCPAddr).Port, mailbox
}
func TestGiteaMailSmoke(t *testing.T) {
binary := os.Getenv("GITEA_SMOKE_BINARY")
if binary == "" {
t.Skip("set GITEA_SMOKE_BINARY to a checksum-verified official Gitea binary")
}
binary, err := filepath.Abs(binary)
if err != nil {
t.Fatal(err)
}
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
version, err := exec.Command(binary, "--version").CombinedOutput()
if err != nil {
t.Fatal(err)
}
fields := strings.Fields(strings.ToLower(string(version)))
if len(fields) < 3 || fields[0] != "gitea" || fields[1] != "version" || fields[2] != strings.TrimPrefix(s.Lock.Tag, "v") {
t.Fatalf("binary does not match snapshot: %s", version)
}
for _, mode := range []string{"shared", "framed"} {
t.Run(mode, func(t *testing.T) { smokeMode(t, binary, s, mode) })
}
}
func smokeMode(t *testing.T, binary string, s *upstream.Snapshot, mode string) {
port, mailbox := smtpCapture(t)
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
webPort := probe.Addr().(*net.TCPAddr).Port
probe.Close()
work := t.TempDir()
// Preseed only Gitea's disposable Git home; never touch the user's Git config.
gitHome := filepath.Join(work, "data", "home")
if err := os.MkdirAll(gitHome, 0755); err != nil {
t.Fatal(err)
}
gitConfig := "[user]\nname = Gitea\nemail = gitea@fake.local\n[core]\nquotepath = false\nlongpaths = true\ncommitGraph = true\n[gc]\nwriteCommitGraph = true\n[fetch]\nwriteCommitGraph = true\n[init]\ndefaultBranch = main\n[receive]\nadvertisePushOptions = true\nprocReceiveRefs = refs/for\n[uploadpack]\nallowfilter = true\nallowAnySHA1InWant = true\n[safe]\ndirectory = *\n"
if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte(gitConfig), 0600); err != nil {
t.Fatal(err)
}
custom := filepath.Join(work, "custom")
configFile := filepath.Join(work, "app.ini")
rootURL := fmt.Sprintf("http://127.0.0.1:%d/", webPort)
ini := fmt.Sprintf("APP_NAME = Smoke Gitea\nRUN_MODE = prod\n[server]\nAPP_DATA_PATH = %s/data\nHTTP_ADDR = 127.0.0.1\nHTTP_PORT = %d\nROOT_URL = %s\nDISABLE_SSH = true\n[database]\nDB_TYPE = sqlite3\nPATH = %s/gitea.db\n[repository]\nROOT = %s/repos\n[security]\nINSTALL_LOCK = true\n[service]\nDISABLE_REGISTRATION = true\nREGISTER_EMAIL_CONFIRM = false\nENABLE_NOTIFY_MAIL = true\n[mailer]\nENABLED = true\nPROTOCOL = smtp\nSMTP_ADDR = 127.0.0.1\nSMTP_PORT = %d\nFROM = Smoke Gitea <smoke@example.invalid>\n[log]\nMODE = console\nLEVEL = warn\n", filepath.ToSlash(work), webPort, rootURL, filepath.ToSlash(work), filepath.ToSlash(work), port)
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
t.Fatal(err)
}
// Disable key-file maintenance and isolate even its unused paths from HOME.
ini = strings.Replace(ini, "DISABLE_SSH = true", "DISABLE_SSH = true\nSSH_ROOT_PATH = "+filepath.ToSlash(filepath.Join(work, "ssh"))+"\nSSH_CREATE_AUTHORIZED_KEYS_FILE = false\nSSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = false", 1)
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
t.Fatal(err)
}
var p *builder.Package
if mode == "framed" {
theme, err := builder.LoadTheme(filepath.Join("..", "..", "themes", "horizon"))
if err != nil {
t.Fatal(err)
}
p, err = builder.Build(s, theme)
if err != nil {
t.Fatal(err)
}
} else {
p, err = builder.Build(s, &builder.Theme{Name: "smoke", Mode: "shared", CSS: "a { color: #123456; }"})
if err != nil {
t.Fatal(err)
}
}
for name, data := range p.Files {
target := filepath.Join(custom, "templates", filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, data, 0644); err != nil {
t.Fatal(err)
}
}
args := []string{"--work-path", work, "--custom-path", custom, "--config", configFile}
run := func(more ...string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, append(append([]string{}, args...), more...)...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Gitea %v: %v\n%s", more, err, output)
}
}
run("migrate")
run("admin", "user", "create", "--username", "smoke-user", "--password", "local-smoke-password-123!", "--email", "smoke@example.invalid", "--admin", "--must-change-password=false")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var logs safeLog
web := exec.CommandContext(ctx, binary, append(args, "web")...)
web.Stdout = &logs
web.Stderr = &logs
if err := web.Start(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = web.Wait(); close(done) }()
t.Cleanup(func() { cancel(); <-done })
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
deadline := time.Now().Add(30 * time.Second)
var page []byte
for time.Now().Before(deadline) {
select {
case <-done:
t.Fatalf("Gitea exited: %s", logs.String())
default:
}
resp, err := client.Get(rootURL + "user/forgot_password")
if err == nil {
page, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
break
}
}
time.Sleep(100 * time.Millisecond)
}
if len(page) == 0 {
t.Fatalf("Gitea did not start: %s", logs.String())
}
if !bytes.Contains(page, []byte(`name="email"`)) {
t.Fatalf("no reset form: %s\n%s", page, logs.String())
}
// Gitea 28 uses origin checks rather than a hidden _csrf form field.
req, err := http.NewRequest(http.MethodPost, rootURL+"user/forgot_password", strings.NewReader(url.Values{"email": {"smoke@example.invalid"}}.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", strings.TrimSuffix(rootURL, "/"))
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("password reset HTTP %d: %s", resp.StatusCode, logs.String())
}
select {
case raw := <-mailbox:
message, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
parts, err := mimeBodies(message.Header.Get("Content-Type"), message.Header.Get("Content-Transfer-Encoding"), message.Body)
if err != nil {
t.Fatal(err)
}
body := parts["text/html"]
if !strings.Contains(body, "/user/recover_account?code=") || !strings.Contains(body, "<style>") {
t.Fatalf("official reset body or injected head missing: %s", body)
}
if mode == "framed" && !strings.Contains(body, `class="email-wrap`) {
t.Fatal("framed override was not loaded")
}
if mode == "framed" && (!strings.Contains(body, `class="email-btn">Recover your account</a>`) || !strings.Contains(body, `src="`+rootURL+`assets/img/favicon.png"`) || strings.Count(body, "/user/recover_account?code=") < 3) {
t.Fatal("shared action label, fallback or instance logo missing from real mail")
}
if mode == "shared" && (!strings.Contains(body, "#123456") || strings.Contains(body, `class="email-wrap`)) {
t.Fatal("shared partial was not loaded")
}
if !strings.Contains(parts["text/plain"], "recover_account") {
t.Fatal("Gitea plain-text alternative missing official recovery link")
}
t.Logf("[PASS] %s %s: real password-reset mail captured; HTML override and plain-text link verified", s.Lock.Tag, mode)
case <-time.After(20 * time.Second):
t.Fatalf("no mail captured: %s", logs.String())
}
}
func mimeBodies(contentType, encoding string, r io.Reader) (map[string]string, error) {
media, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, err
}
result := map[string]string{}
if strings.HasPrefix(media, "multipart/") {
reader := multipart.NewReader(r, params["boundary"])
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
bodies, err := mimeBodies(part.Header.Get("Content-Type"), part.Header.Get("Content-Transfer-Encoding"), part)
if err != nil {
return nil, err
}
for k, v := range bodies {
result[k] = v
}
}
return result, nil
}
switch strings.ToLower(encoding) {
case "quoted-printable":
r = quotedprintable.NewReader(r)
case "base64":
r = base64.NewDecoder(base64.StdEncoding, r)
}
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
result[media] = string(b)
return result, nil
}
+249 -270
View File
@@ -1,313 +1,292 @@
package preview
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
texttmpl "text/template"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/upstream"
)
// RenderResult holds the rendered HTML for a single template in a single theme.
type RenderResult struct {
HTML string
Size int64 // bytes of the rendered HTML
Error string
Skipped bool // true when the template file was not found
}
// ThemeSummary collects statistics for one theme directory.
type ThemeSummary struct {
Name string
TotalFiles int
TotalSize int64 // total size of .tmpl source files
RenderedSize int64 // total size of rendered HTML
RenderedCount int
MissingCount int
ErrorCount int
RenderDuration time.Duration
Missing []string // template IDs that were not found
Errors []string // template IDs that had render errors
Results map[string]*RenderResult // per-template results
}
// PreviewResult is the full output from a preview run.
type PreviewResult struct {
Rendered map[string]map[string]string `json:"rendered"`
Registry map[string]map[string]string `json:"registry"`
Params map[string]map[string]string `json:"params"`
Summaries []ThemeSummary `json:"-"`
Rendered map[string]map[string]map[string]string
Registry map[string]map[string]string
Params map[string]map[string]string
Languages []string
Themes []string
Upstream string
Commit string
Logo []byte
Fallback map[string][]string
Warnings map[string]map[string][]string
}
// DiscoverThemes scans a directory and returns all subdirectory names.
func DiscoverThemes(themesDir string) ([]string, error) {
entries, err := os.ReadDir(themesDir)
if err != nil {
return nil, fmt.Errorf("cannot read themes directory %s: %w", themesDir, err)
func DiscoverThemes(dir string) ([]string, error) { return builder.Discover(dir) }
// AlignConfig discovers all entrypoints from the snapshot. Metadata is a fixture
// catalog, not a second list of templates. Uncovered new mail types fail closed.
func AlignConfig(s *upstream.Snapshot, cfg *config.TemplatesConfig) error {
found := map[string]bool{}
for _, p := range s.Entrypoints() {
id := upstream.TemplateID(p)
if found[id] {
return fmt.Errorf("duplicate entrypoint ID %s", id)
}
found[id] = true
fixture, ok := cfg.Templates[id]
if !ok {
return fmt.Errorf("new upstream mail type %s needs a preview fixture", p)
}
fixture.Path = strings.Split(p, "/")
cfg.Templates[id] = fixture
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
for id := range cfg.Templates {
if !found[id] {
return fmt.Errorf("stale fixture %s is absent from upstream snapshot", id)
}
}
if len(names) == 0 {
return nil, fmt.Errorf("no theme directories found in %s", themesDir)
}
return names, nil
}
// RenderAll renders all templates from the config for all discovered themes.
// If themeFilter is non-empty, only themes in the filter set are rendered.
func RenderAll(themesDir string, cfg *config.TemplatesConfig, themeFilter map[string]bool) *PreviewResult {
themes, err := DiscoverThemes(themesDir)
if err != nil {
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [Preview] Error discovering themes: %v\n", err)
return nil
}
// Apply theme filter
if len(themeFilter) > 0 {
filtered := themes[:0]
for _, t := range themes {
if themeFilter[t] {
filtered = append(filtered, t)
}
}
themes = filtered
if len(themes) == 0 {
fmt.Println("\033[33m[W]\033[0m [Preview] No themes matched the requested filter")
return nil
}
}
result := &PreviewResult{
Rendered: make(map[string]map[string]string),
Registry: config.Registry(cfg),
}
// Build flattened params for the preview panel
result.Params = make(map[string]map[string]string, len(cfg.Templates))
for id, t := range cfg.Templates {
result.Params[id] = config.FlattenParams(t.Params)
}
for _, themeName := range themes {
themeDir := filepath.Join(themesDir, themeName)
summary := renderTheme(themeDir, themeName, cfg)
result.Summaries = append(result.Summaries, summary)
if result.Rendered[themeName] == nil {
result.Rendered[themeName] = make(map[string]string)
}
for tplID, rr := range summary.Results {
if rr.Error != "" {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Render error for %s: %s</p>", tplID, rr.Error)
} else if rr.Skipped {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Template not found: %s</p>", tplID)
} else {
result.Rendered[themeName][tplID] = rr.HTML
}
}
}
return result
}
func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeSummary {
summary := ThemeSummary{
Name: themeName,
Results: make(map[string]*RenderResult),
}
start := time.Now()
for tplID, tplCfg := range cfg.Templates {
tmplRelPath := tplCfg.PathStr()
tmplAbsPath := filepath.Join(themeDir, tmplRelPath)
// Check source file existence
info, err := os.Stat(tmplAbsPath)
if os.IsNotExist(err) {
summary.MissingCount++
summary.Missing = append(summary.Missing, tplID)
summary.Results[tplID] = &RenderResult{Skipped: true}
continue
}
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: err.Error()}
continue
}
summary.TotalFiles++
summary.TotalSize += info.Size()
// Read template content
tmplContent, err := os.ReadFile(tmplAbsPath)
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("read error: %v", err)}
continue
}
// Build data context
data := make(map[string]any)
for k, v := range tplCfg.Params {
data[k] = v
}
data["locale"] = Locale{}
// Convert HTML-safe fields from string to template.HTML so Go's
// html/template renders them raw instead of escaping. Matches Gitea's
// behavior where RenderedNote / Body / RenderedContent are template.HTML.
markSafeHTML(data)
// Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("parse error: %v", err)}
continue
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("render error: %v", err)}
continue
}
rendered := buf.String()
summary.RenderedCount++
summary.RenderedSize += int64(len(rendered))
summary.Results[tplID] = &RenderResult{
HTML: rendered,
Size: int64(len(rendered)),
}
}
summary.RenderDuration = time.Since(start)
return summary
}
// WriteRenderedJS writes the preview result to a JavaScript file loaded by preview/index.html.
func WriteRenderedJS(result *PreviewResult, outputPath string) error {
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
}
defer outFile.Close()
outFile.WriteString("// Auto-generated by tools/tools.go — do not edit\n")
// Write __RENDERED__
renderedJSON, err := json.MarshalIndent(result.Rendered, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal rendered data: %w", err)
}
outFile.WriteString("window.__RENDERED__ = ")
outFile.Write(renderedJSON)
outFile.WriteString(";\n\n")
// Write __REGISTRY__
registryJSON, err := json.MarshalIndent(result.Registry, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal registry data: %w", err)
}
outFile.WriteString("window.__REGISTRY__ = ")
outFile.Write(registryJSON)
outFile.WriteString(";\n\n")
// Write __PARAMS__
paramsJSON, err := json.MarshalIndent(result.Params, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal params data: %w", err)
}
outFile.WriteString("window.__PARAMS__ = ")
outFile.Write(paramsJSON)
outFile.WriteString(";\n")
return nil
}
// PrintDetailedSummary prints a human-readable summary of the preview results.
func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.TemplatesConfig) {
absDir, _ := filepath.Abs(themesDir)
fmt.Printf("\033[32m[I]\033[0m [Preview] Found %d styles in '%s':\n",
len(result.Summaries), filepath.Base(absDir))
for _, s := range result.Summaries {
if s.MissingCount == 0 && s.ErrorCount == 0 {
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s all required templates OK (total %d .tmpl, %s, %v)\n",
s.Name, s.TotalFiles,
formatSize(s.RenderedSize),
s.RenderDuration.Round(time.Millisecond))
} else {
fmt.Printf("\033[33m[W]\033[0m [Preview] %-16s only %d required templates rendered:\n",
s.Name, s.RenderedCount)
for _, tplID := range s.Missing {
if t, ok := cfg.Templates[tplID]; ok {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found, expected at 'themes/%s/%s'\n",
filepath.Base(t.PathStr()), s.Name, t.PathStr())
} else {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found\n", tplID)
// RenderAll builds installable overrides and renders every official language.
func RenderAll(themesDir string, cfg *config.TemplatesConfig, filter map[string]bool) (*PreviewResult, error) {
root := filepath.Dir(themesDir)
s, err := upstream.Prepare(context.Background(), root)
if err != nil {
return nil, err
}
if err := AlignConfig(s, cfg); err != nil {
return nil, err
}
packages, err := builder.BuildAll(themesDir, s, filter)
if err != nil {
return nil, err
}
if err := builder.Write(filepath.Join(root, "build", "themes"), s, packages); err != nil {
return nil, err
}
// Include framework-added labels/controls, not only stock mail keys.
reference := *s
reference.Templates = map[string][]byte{}
for name, b := range s.Templates {
reference.Templates[name] = b
}
for _, p := range packages {
for name, b := range p.Files {
reference.Templates[name] = b
}
}
missing, err := upstream.MissingKeys(&reference)
if err != nil {
return nil, err
}
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{}, Registry: config.Registry(cfg), Params: map[string]map[string]string{}, Languages: s.Languages(), Upstream: s.Lock.Tag, Fallback: missing, Warnings: map[string]map[string][]string{}}
for id, t := range cfg.Templates {
result.Params[id] = config.FlattenParams(t.Params)
}
for name := range packages {
result.Themes = append(result.Themes, name)
}
sort.Strings(result.Themes)
result.Commit = s.Lock.Commit
result.Logo = s.Files["public/assets/img/favicon.png"]
for _, lang := range result.Languages {
result.Rendered[lang] = map[string]map[string]string{}
result.Warnings[lang] = map[string][]string{}
for _, name := range result.Themes {
templates := builder.EffectiveTemplates(s, packages[name])
result.Rendered[lang][name] = map[string]string{}
for id, fixture := range cfg.Templates {
mail, err := RenderMail(s, templates, strings.Join(fixture.Path, "/"), fixture.Params, lang)
if err != nil {
return nil, fmt.Errorf("%s/%s/%s: %w", lang, name, id, err)
}
}
for _, tplID := range s.Errors {
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error: %s\n", tplID, rr.Error)
} else {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error\n", tplID)
result.Rendered[lang][name][id] = mail.HTML
if len(mail.Warnings) > 0 {
result.Warnings[lang][id] = mail.Warnings
}
}
}
}
return result, nil
}
type Mail struct {
Subject string
HTML string
Warnings []string
}
var subjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
// RenderMail parses subjects with text/template, bodies with html/template, and
// registers shared partials under Gitea's mail/... names.
func RenderMail(s *upstream.Snapshot, files map[string][]byte, entry string, params map[string]any, lang string) (Mail, error) {
var result Mail
catalog, ok := s.Locales[lang]
if !ok {
return result, fmt.Errorf("unknown preview language %s", lang)
}
data := CloneParams(params)
var problems []error
data["locale"] = Locale{Lang: lang, Catalog: catalog, English: s.Locales["en-US"], Problems: &problems, Warnings: &result.Warnings}
markSafeHTML(data)
root := template.New("").Funcs(TemplateFuncs).Option("missingkey=error")
var entrySubject string
names := make([]string, 0, len(files))
for p := range files {
names = append(names, p)
}
sort.Strings(names)
for _, p := range names {
source := string(files[p])
subject, body := splitMail(source)
if p == entry {
entrySubject = subject
}
if _, err := root.New(builder.TemplateName(p)).Parse(body); err != nil {
return result, fmt.Errorf("%s: %w", p, err)
}
}
if entrySubject != "" {
// Gitea's subject environment is intentionally smaller than the body map.
sub, err := texttmpl.New("subject").Funcs(texttmpl.FuncMap{"AppName": TemplateFuncs["AppName"], "AppDomain": TemplateFuncs["AppDomain"], "dict": Dict}).Option("missingkey=error").Parse(entrySubject)
if err != nil {
return result, err
}
var buf strings.Builder
if err := sub.Execute(&buf, data); err != nil {
return result, err
}
result.Subject = strings.Join(strings.Fields(buf.String()), " ")
runes := []rune(result.Subject)
if len(runes) > 256 {
result.Subject = string(runes[:256])
}
}
if result.Subject == "" {
if subject, ok := data["FallbackSubject"].(string); ok {
result.Subject = subject
} else if subject, ok := data["Subject"].(string); ok {
result.Subject = subject
}
}
data["Subject"] = result.Subject
var buf strings.Builder
if err := root.ExecuteTemplate(&buf, builder.TemplateName(entry), data); err != nil {
return result, err
}
if len(problems) > 0 {
return result, problems[0]
}
result.HTML = buf.String()
return result, nil
}
func splitMail(source string) (string, string) {
loc := subjectSplit.FindStringIndex(source)
if loc == nil {
return "", source
}
return source[:loc[0]], source[loc[1]:]
}
// CloneParams prevents safe-HTML conversions or branch fixtures from mutating
// another language's input data.
func CloneParams(params map[string]any) map[string]any {
result := map[string]any{}
for k, v := range params {
switch v := v.(type) {
case map[string]any:
result[k] = CloneParams(v)
case []any:
values := make([]any, len(v))
for i, item := range v {
if m, ok := item.(map[string]any); ok {
values[i] = CloneParams(m)
} else {
values[i] = item
}
}
result[k] = values
default:
result[k] = v
}
}
return result
}
// The small manifest is always loaded first; each catalog's rendered data is a
// classic script so static preview also works without fetch on file://.
func WriteRenderedJS(result *PreviewResult, output string) error {
dir := filepath.Dir(output)
dataDir := filepath.Join(dir, "rendered")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
manifest := map[string]any{"schema": 1, "upstream": result.Upstream, "languages": result.Languages, "themes": result.Themes, "registry": result.Registry, "params": result.Params, "fallback": result.Fallback, "warnings": result.Warnings}
manifest["logo"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(result.Logo)
manifest["upstream_commit"] = result.Commit
data, err := json.Marshal(manifest)
if err != nil {
return err
}
// Payloads are written before the manifest, so a failed render never publishes
// a manifest that advertises missing files.
for _, lang := range result.Languages {
payload, err := json.Marshal(result.Rendered[lang])
if err != nil {
return err
}
key, _ := json.Marshal(lang)
script := "window.__MAIL_PREVIEW_DATA__ = window.__MAIL_PREVIEW_DATA__ || {};\nwindow.__MAIL_PREVIEW_DATA__[" + string(key) + "] = " + string(payload) + ";\n"
if err := os.WriteFile(filepath.Join(dataDir, lang+".js"), []byte(script), 0644); err != nil {
return err
}
}
script := "// Generated by tools; do not edit.\nwindow.__MAIL_PREVIEW__ = " + string(data) + ";\n"
return os.WriteFile(output, []byte(script), 0644)
}
func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesConfig) {
fmt.Printf("[PASS] %s: %d themes × %d mail types × %d languages\n", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
for _, lang := range result.Languages {
for _, warnings := range result.Warnings[lang] {
for _, warning := range warnings {
fmt.Println(warning)
}
}
if keys := result.Fallback[lang]; len(keys) > 0 {
fmt.Printf("[FALLBACK] %s -> en-US: %s\n", lang, strings.Join(keys, ", "))
}
}
}
// markSafeHTML recursively converts known HTML-containing string fields to
// template.HTML so Go's html/template renders them raw instead of escaping.
// These fields mirror Gitea's data model where rendered markdown is typed as
// template.HTML: Release.RenderedNote, Body (comment content), and
// ReviewComments[].RenderedContent.
func markSafeHTML(data map[string]any) {
for k, v := range data {
switch val := v.(type) {
switch v := v.(type) {
case string:
if k == "RenderedNote" || k == "Body" || k == "RenderedContent" {
data[k] = template.HTML(val)
data[k] = template.HTML(v)
}
case map[string]any:
markSafeHTML(val)
markSafeHTML(v)
case []any:
for i, item := range val {
for _, item := range v {
if m, ok := item.(map[string]any); ok {
markSafeHTML(m)
val[i] = m
}
}
}
}
}
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
return fmt.Sprintf("%dKiB", bytes/unit)
}
+4 -11
View File
@@ -9,9 +9,11 @@ import (
// TemplateFuncs maps Gitea's built-in template functions available in mail templates.
var TemplateFuncs = template.FuncMap{
"dict": Dict,
"AppUrl": func() string { return "https://gitea.com/" },
"AppName": func() string { return "Gitea" },
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
"AppDomain": func() string { return "gitea.com" },
"DotEscape": func(s string) string { return strings.ReplaceAll(s, ".", "\u200d.\u200d") },
"QueryEscape": url.QueryEscape,
"ShortSha": ShortSha,
"PathEscapeSegments": PathEscapeSegments,
@@ -69,16 +71,7 @@ func FormatByteSize(n int64) string {
// HTMLFormat substitutes %s/%d placeholders with HTML-escaped values.
func HTMLFormat(fmtStr string, args ...any) template.HTML {
result := fmtStr
for i, arg := range args {
escaped := template.HTMLEscapeString(fmt.Sprint(arg))
result = strings.Replace(result, fmt.Sprintf("%%[%d]s", i+1), escaped, 1)
if !strings.Contains(result, fmt.Sprintf("%%[%d]s", i+1)) {
result = strings.Replace(result, "%s", escaped, 1)
result = strings.Replace(result, "%d", fmt.Sprint(arg), 1)
}
}
return template.HTML(result)
return template.HTML(fmt.Sprintf(fmtStr, escapedArgs(args)...))
}
// Dict builds a map from alternating key/value pairs (used by Gitea's Dict function).
+98 -112
View File
@@ -2,129 +2,115 @@ package preview
import (
"fmt"
"gitea-mail-templates/tools/upstream"
"html/template"
"reflect"
"strings"
)
// Locale provides English fallback translations, matching Gitea's locale.Tr interface.
type Locale struct{}
// Locale is a lightweight adapter for the translation implementation pinned in
// upstream/modules/translation. Catalogs are always official snapshot data.
type Locale struct {
Lang string
Catalog map[string]string
English map[string]string
Problems *[]error
Warnings *[]string
}
func (l Locale) Language() string { return l.Lang }
func (l Locale) HasKey(key string) bool { _, ok := l.Catalog[key]; return ok }
func (l Locale) problem(err error) {
if l.Problems != nil {
*l.Problems = append(*l.Problems, err)
}
}
func (l Locale) TrString(key string, args ...any) string {
format := l.Catalog[key]
if format == "" {
format = l.English[key]
}
if format == "" {
l.problem(fmt.Errorf("missing translation key %s", key))
format = template.HTMLEscapeString(key)
}
if len(args) == 0 {
return format
}
values := make([]any, 0, len(args))
for _, a := range args {
v := reflect.ValueOf(a)
if v.IsValid() && v.Kind() == reflect.Slice {
if len(args) != 1 {
l.problem(fmt.Errorf("translation %s has uncertain slice arguments", key))
break
}
for i := 0; i < v.Len(); i++ {
values = append(values, v.Index(i).Interface())
}
} else {
values = append(values, a)
}
}
msg := fmt.Sprintf(format, values...)
// Upstream logs formatting failures; a fixture preview must surface them.
if strings.Contains(msg, "%!") && upstream.ReviewedFormattingIssue(l.Lang, key, format) {
if l.Warnings != nil {
*l.Warnings = append(*l.Warnings, "[UPSTREAM-WARN] "+l.Lang+" "+key+": official placeholder %[1]s is malformed; output matches Gitea")
}
} else if strings.Contains(msg, "%!") {
l.problem(fmt.Errorf("translation %s formatting failed: %s", key, msg))
}
return msg
}
func escapedArgs(args []any) []any {
out := append([]any(nil), args...)
for i, a := range out {
switch a := a.(type) {
case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
case string:
out[i] = template.HTMLEscapeString(a)
case fmt.Stringer:
out[i] = template.HTMLEscapeString(a.String())
default:
out[i] = template.HTMLEscapeString(fmt.Sprint(a))
}
}
return out
}
// Tr translates a key and substitutes %s / %d / %[N]s / %[N]d arguments.
// HTML arguments are preserved; plain strings are HTML-escaped.
func (l Locale) Tr(key string, args ...any) template.HTML {
s := LocaleEN[key]
if s == "" {
s = key
}
for i, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h) // already safe HTML
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
// Handle positional format specifiers: %[1]s, %[2]d, etc.
pos := fmt.Sprintf("%%[%d]", i+1)
s = strings.Replace(s, pos+"s", val, 1)
s = strings.Replace(s, pos+"d", val, 1)
}
// Fallback for non-positional %s / %d (sequential replacement)
for _, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h)
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
if !strings.Contains(s, "%s") && !strings.Contains(s, "%d") {
break
}
s = strings.Replace(s, "%s", val, 1)
s = strings.Replace(s, "%d", val, 1)
}
return template.HTML(s)
return template.HTML(l.TrString(key, escapedArgs(args)...))
}
// TrN picks singular/plural by count, then delegates to Tr.
func (l Locale) TrN(count any, singular, plural string, args ...any) template.HTML {
n := 0
switch v := count.(type) {
func (l Locale) TrN(count any, one, many string, args ...any) template.HTML {
var n int64
switch count := count.(type) {
case int:
n = v
n = int64(count)
case int16:
n = int64(count)
case int32:
n = int64(count)
case int64:
n = int(v)
case float64:
n = int(v)
n = count
default:
n = 1
return l.Tr(many, args...)
}
key := plural
if n == 1 {
key = singular
singular := n == 1
switch l.Lang {
case "zh-CN", "zh-HK", "zh-TW":
singular = true
case "lv-LV", "ru-RU":
singular = n%10 == 1 && n%100 != 11
case "fr-FR":
singular = n > -2 && n < 2
}
return l.Tr(key, args...)
}
// LocaleEN holds all English translation strings used by Gitea mail templates.
var LocaleEN = map[string]string{
"mail.hi_user_x": "Hi <b>%s</b>,",
"mail.activate_account": "Please activate your account",
"mail.activate_account.title": "%s, please activate your account",
"mail.activate_account.text_1": "Hi <b>%[1]s</b>, thanks for registering at %[2]s!",
"mail.activate_account.text_2": "Please click the following link to activate your account within <b>%s</b>:",
"mail.activate_email": "Verify your email address",
"mail.activate_email.title": "%s, please verify your email address",
"mail.activate_email.text": "Please click the following link to verify your email address within <b>%s</b>:",
"mail.register_success": "Registration successful",
"mail.register_notify": "Welcome to %s",
"mail.register_notify.title": "%[1]s, welcome to %[2]s",
"mail.register_notify.text_1": "This is your registration confirmation email for %s!",
"mail.register_notify.text_2": "You can now log in via username: %s.",
"mail.register_notify.text_3": "If this account has been created for you, please <a href=\"%s\">set your password</a> first.",
"mail.reset_password": "Recover your account",
"mail.reset_password.title": "%s, you have requested to recover your account",
"mail.reset_password.text": "Please click the following link to recover your account within <b>%s</b>:",
"mail.team_invite.subject": "%[1]s has invited you to join the %[2]s organization",
"mail.team_invite.text_1": "%[1]s has invited you to join team %[2]s in organization %[3]s.",
"mail.team_invite.text_2": "Please click the following link to join the team:",
"mail.team_invite.text_3": "Note: This invitation was intended for %[1]s. If you were not expecting this invitation, you can ignore this email.",
"mail.repo.collaborator.added.subject": "%s added you to %s",
"mail.repo.collaborator.added.text": "You have been added as a collaborator of repository:",
"mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s",
"mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you",
"mail.repo.transfer.to_you": "you",
"mail.repo.transfer.body": "To accept or reject it, visit %s or just ignore it.",
"mail.release.new.subject": "%s in %s released",
"mail.release.new.text": "<b>@%[1]s</b> released %[2]s in %[3]s",
"mail.release.title": "Title: %s",
"mail.release.note": "Note:",
"mail.release.downloads": "Downloads:",
"mail.release.download.zip": "Source Code (ZIP)",
"mail.release.download.targz": "Source Code (TAR.GZ)",
"mail.repo.actions.run.failed": "Run failed",
"mail.repo.actions.run.succeeded": "Run succeeded",
"mail.repo.actions.run.cancelled": "Run cancelled",
"mail.repo.actions.jobs.all_succeeded": "All jobs have succeeded",
"mail.repo.actions.jobs.all_failed": "All jobs have failed",
"mail.repo.actions.jobs.some_not_successful": "Some jobs were not successful",
"mail.repo.actions.jobs.all_cancelled": "All jobs have been cancelled",
"mail.issue_assigned.pull": "@%[1]s assigned you to pull request %[2]s in repository %[3]s.",
"mail.issue_assigned.issue": "@%[1]s assigned you to issue %[2]s in repository %[3]s.",
"mail.issue.x_mentioned_you": "<b>@%s</b> mentioned you:",
"mail.issue.action.close": "<b>@%[1]s</b> closed #%[2]d.",
"mail.issue.action.reopen": "<b>@%[1]s</b> reopened #%[2]d.",
"mail.issue.action.merge": "<b>@%[1]s</b> merged #%[2]d into %[3]s.",
"mail.issue.action.approve": "<b>@%[1]s</b> approved this pull request.",
"mail.issue.action.reject": "<b>@%[1]s</b> requested changes on this pull request.",
"mail.issue.action.review": "<b>@%[1]s</b> commented on this pull request.",
"mail.issue.action.review_dismissed": "<b>@%[1]s</b> dismissed last review from %[2]s for this pull request.",
"mail.issue.action.ready_for_review": "<b>@%[1]s</b> marked this pull request ready for review.",
"mail.issue.action.new": "<b>@%[1]s</b> created #%[2]d.",
"mail.issue.action.force_push": "<b>%[1]s</b> force-pushed the <b>%[2]s</b> from %[3]s to %[4]s.",
"mail.issue.action.push_1": "<b>@%[1]s</b> pushed %[3]d commit to %[2]s",
"mail.issue.action.push_n": "<b>@%[1]s</b> pushed %[3]d commits to %[2]s",
"mail.issue.in_tree_path": "In %s:",
"mail.view_it_on": "View it on %s",
"mail.reply": "or reply to this email directly",
"mail.link_not_working_do_paste": "Not working? Try copying and pasting it to your browser.",
if singular {
return l.Tr(one, args...)
}
return l.Tr(many, args...)
}
+317
View File
@@ -0,0 +1,317 @@
package preview
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/upstream"
"golang.org/x/net/html"
)
type testCommitID string
func (id testCommitID) String() string { return string(id) }
type scenario struct {
id, name string
params map[string]any
}
func branchFixtures(cfg *config.TemplatesConfig) []scenario {
var result []scenario
for id, f := range cfg.Templates {
result = append(result, scenario{id, "default", f.Params})
}
add := func(id, name string, edit func(map[string]any)) {
data := CloneParams(cfg.Templates[id].Params)
edit(data)
result = append(result, scenario{id, name, data})
}
for _, action := range []string{"new", "close", "reopen", "merge", "approve", "reject", "review", "review_dismissed", "ready_for_review"} {
add("default", action, func(d map[string]any) {
d["ActionName"] = action
d["Body"] = ""
d["IsMention"] = true
d["CanReply"] = true
})
}
for _, n := range []int{0, 1, 2} {
add("default", fmt.Sprintf("push-%d", n), func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
commits := make([]any, n)
for i := range commits {
commits[i] = map[string]any{"UserCommit": map[string]any{"GitCommit": map[string]any{"ID": testCommitID("0123456789abcdef0123456789abcdef01234567"), "MessageTitle": "Commit <escaped>"}}}
}
c["Commits"] = commits
})
}
add("default", "force-push", func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
c["IsForcePush"] = true
c["OldCommit"] = "0123456789abcdef"
c["NewCommit"] = "fedcba9876543210"
})
add("default", "review-comments", func(d map[string]any) {
d["ReviewComments"] = []any{map[string]any{"TreePath": "src/example.go", "Patch": "<unsafe>& patch", "RenderedContent": "<p>Safe <strong>review</strong></p>"}}
})
add("assigned", "pull-reply", func(d map[string]any) { d["IsPull"] = true; d["CanReply"] = true })
add("release", "no-downloads-or-note", func(d map[string]any) {
d["DisableDownloadSourceArchives"] = true
r := d["Release"].(map[string]any)
r["Attachments"] = []any{}
r["RenderedNote"] = ""
})
add("workflow_run", "no-jobs", func(d map[string]any) { d["Jobs"] = []any{} })
add("workflow_run", "failure-jobs", func(d map[string]any) {
for _, job := range d["Jobs"].([]any) {
j := job.(map[string]any)
j["StatusClass"] = "status-failure"
j["StatusIconCID"] = "failure"
j["StatusIconAlt"] = "Failure"
}
})
add("activate", "escaped-name", func(d map[string]any) { d["DisplayName"] = "test.example <User> & \"Admin\""; d["Code"] = "a&b= c" })
return result
}
type semantics struct {
Text string
Links, Images []string
}
func mailSemantics(source string, hints ...string) (semantics, error) {
root, err := html.Parse(strings.NewReader(source))
if err != nil {
return semantics{}, err
}
var result semantics
var text []string
var visible func(*html.Node) string
visible = func(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
if n.Type == html.ElementNode && (n.Data == "head" || n.Data == "script" || n.Data == "style") {
return ""
}
var parts []string
for c := n.FirstChild; c != nil; c = c.NextSibling {
parts = append(parts, visible(c))
}
return strings.Join(parts, " ")
}
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "head" {
return
}
if n.Type == html.ElementNode {
for _, a := range n.Attr {
if a.Key == "class" && strings.Contains(" "+a.Val+" ", " framework-extra ") {
return
}
}
if n.Data == "p" {
for _, hint := range hints {
if strings.Join(strings.Fields(visible(n)), " ") == strings.Join(strings.Fields(hint), " ") {
return
}
}
}
}
if n.Type == html.TextNode {
text = append(text, n.Data)
}
if n.Type == html.ElementNode {
if n.Data == "a" {
for _, a := range n.Attr {
if a.Key == "href" {
result.Links = append(result.Links, a.Val+" | "+strings.Join(strings.Fields(visible(n)), " "))
}
}
}
if n.Data == "img" {
var attrs []string
for _, a := range n.Attr {
if a.Key == "src" || a.Key == "alt" {
attrs = append(attrs, a.Key+"="+a.Val)
}
}
result.Images = append(result.Images, strings.Join(attrs, " | "))
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
result.Text = strings.Join(strings.Fields(strings.Join(text, " ")), " ")
return result, nil
}
// Run every discovered language and theme against stock Gitea for both normal
// fixtures and branches that historically broke custom mail overrides.
func TestAllThemeContentParity(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
if err := AlignConfig(s, cfg); err != nil {
t.Fatal(err)
}
packages, err := builder.BuildAll(filepath.Join("..", "..", "themes"), s, nil)
if err != nil {
t.Fatal(err)
}
// Shared mode has the same parity guarantee although migrated themes are framed.
shared, err := builder.Build(s, &builder.Theme{Name: "test", Mode: "shared", CSS: "a { color:#2563eb; }"})
if err != nil {
t.Fatal(err)
}
packages["shared-test"] = shared
for _, lang := range s.Languages() {
t.Run(lang, func(t *testing.T) {
for _, fixture := range branchFixtures(cfg) {
entry := strings.Join(cfg.Templates[fixture.id].Path, "/")
stock, err := RenderMail(s, s.Templates, entry, fixture.params, lang)
if err != nil {
t.Fatalf("stock %s/%s: %v", fixture.id, fixture.name, err)
}
hint := Locale{Lang: lang, Catalog: s.Locales[lang], English: s.Locales["en-US"]}.TrString("mail.link_not_working_do_paste")
want, err := mailSemantics(stock.HTML, hint)
if err != nil {
t.Fatal(err)
}
for theme, p := range packages {
mail, err := RenderMail(s, builder.EffectiveTemplates(s, p), entry, fixture.params, lang)
if err != nil {
t.Fatalf("%s/%s/%s: %v", theme, fixture.id, fixture.name, err)
}
got, err := mailSemantics(mail.HTML, hint)
if err != nil {
t.Fatal(err)
}
if stock.Subject != mail.Subject || !reflect.DeepEqual(want, got) {
t.Fatalf("content changed for %s/%s/%s", theme, fixture.id, fixture.name)
}
if fixture.id == "release" && fixture.name == "default" && (!strings.Contains(mail.HTML, "1.5 KiB") || !strings.Contains(mail.HTML, "release-notes.txt")) {
t.Fatal("release attachment regression")
}
if fixture.name == "push-1" && !strings.Contains(mail.HTML, "/commit/0123456789abcdef0123456789abcdef01234567") {
t.Fatal("push link regression")
}
}
}
})
}
}
func TestLocaleFormatting(t *testing.T) {
var problems []error
base := map[string]string{"repeat": "%[1]s %[1]s (%[2]d)", "mixed": "%[2]s %s %%", "html": "<b>%s</b> %s", "one": "one", "many": "many", "fallback": "English", "broken": "%d"}
l := Locale{Lang: "en-US", Catalog: base, English: base, Problems: &problems}
if got := string(l.Tr("repeat", "<x>", 7)); got != "&lt;x&gt; &lt;x&gt; (7)" {
t.Fatal(got)
}
if got := l.TrString("mixed", "ignored", "second", "third"); got != "second third %" {
t.Fatal(got)
}
if got := string(l.Tr("html", "<unsafe>", template.HTML("<i>safe</i>"))); got != "<b>&lt;unsafe&gt;</b> <i>safe</i>" {
t.Fatal(got)
}
if got := l.TrString("repeat", []any{"slice", int64(2)}); got != "slice slice (2)" {
t.Fatal(got)
}
for _, tc := range []struct {
lang string
n any
want string
}{{"en-US", 0, "many"}, {"en-US", 1, "one"}, {"en-US", 2, "many"}, {"zh-CN", 2, "one"}, {"fr-FR", 0, "one"}, {"ru-RU", 21, "one"}, {"ru-RU", 11, "many"}, {"en-US", float64(1), "many"}} {
l.Lang = tc.lang
if got := string(l.TrN(tc.n, "one", "many")); got != tc.want {
t.Fatalf("%v: %s", tc, got)
}
}
l.Catalog = map[string]string{}
if got := l.TrString("fallback"); got != "English" {
t.Fatal(got)
}
if len(problems) != 0 {
t.Fatal(problems)
}
l.Tr("missing")
l.Tr("broken", "text")
if len(problems) != 2 {
t.Fatal("missing/formatting errors were not surfaced")
}
if got := string(HTMLFormat("<a href='%[1]s'>%[2]s</a>", "x?a=1&b=2", template.HTML("<b>safe</b>"))); got != "<a href='x?a=1&amp;b=2'><b>safe</b></a>" {
t.Fatal(got)
}
}
func TestSubjectSplitAndMissingFixture(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
files := builder.EffectiveTemplates(s, nil)
files["mail/test.tmpl"] = []byte(" {{.Subject}}\n------------\n<body>{{.Subject}}</body>")
mail, err := RenderMail(s, files, "mail/test.tmpl", map[string]any{"Subject": " Hello world "}, "en-US")
if err != nil {
t.Fatal(err)
}
if mail.Subject != "Hello world" || strings.TrimSpace(mail.HTML) != "<body>Hello world</body>" {
t.Fatal(mail)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
delete(cfg.Templates, "release")
if AlignConfig(s, cfg) == nil {
t.Fatal("missing fixture accepted")
}
if _, err := RenderMail(s, s.Templates, "mail/repo/release.tmpl", map[string]any{}, "en-US"); err == nil {
t.Fatal("missing context accepted")
}
}
func TestPreviewBundles(t *testing.T) {
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"}
output := filepath.Join(t.TempDir(), "rendered.js")
if err := WriteRenderedJS(result, output); err != nil {
t.Fatal(err)
}
manifest, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(manifest, []byte("<body>")) {
t.Fatal("manifest embeds mail payload")
}
for _, lang := range result.Languages {
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("window.__MAIL_PREVIEW_DATA__")) {
t.Fatal("not a file:// script bundle")
}
}
}
-67
View File
@@ -1,67 +0,0 @@
package preview
import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"gitea-mail-templates/tools/config"
)
type testCommitID string
func (id testCommitID) String() string { return string(id) }
// Exercise the push-only branch with Gitea's nested commit data shape.
// The normal preview fixture has no commits, so it cannot catch stale .ID paths.
func TestPushCommitTemplates(t *testing.T) {
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
const sha = "0123456789abcdef0123456789abcdef01234567"
data := cfg.Templates["default"].Params
data["ActionName"] = "push"
data["locale"] = Locale{}
comment := data["Comment"].(map[string]any)
comment["Commits"] = []any{map[string]any{
"UserCommit": map[string]any{
"GitCommit": map[string]any{
"ID": testCommitID(sha),
"MessageTitle": "Test push commit",
},
},
}}
markSafeHTML(data)
themesDir := filepath.Join("..", "..", "themes")
themes, err := os.ReadDir(themesDir)
if err != nil {
t.Fatal(err)
}
for _, theme := range themes {
if !theme.IsDir() {
continue
}
t.Run(theme.Name(), func(t *testing.T) {
path := filepath.Join(themesDir, theme.Name(), "mail", "repo", "issue", "default.tmpl")
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
tmpl, err := template.New("push").Funcs(TemplateFuncs).Parse(string(content))
if err != nil {
t.Fatal(err)
}
var html strings.Builder
if err := tmpl.Execute(&html, data); err != nil {
t.Fatal(err)
}
if !strings.Contains(html.String(), "/commit/"+sha) || !strings.Contains(html.String(), sha[:10]) {
t.Fatal("push commit link or abbreviated hash missing from rendered email")
}
})
}
}
-53
View File
@@ -1,53 +0,0 @@
package preview
import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"gitea-mail-templates/tools/config"
)
// Gitea 28 removes FileSize, so render an attachment in every release theme.
func TestReleaseAttachmentsGitea28(t *testing.T) {
if _, ok := TemplateFuncs["FileSize"]; ok {
t.Fatal("FileSize is not available in Gitea 28 mail templates")
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
data := cfg.Templates["release"].Params
data["locale"] = Locale{}
markSafeHTML(data)
themesDir := filepath.Join("..", "..", "themes")
themes, err := os.ReadDir(themesDir)
if err != nil {
t.Fatal(err)
}
for _, theme := range themes {
if !theme.IsDir() {
continue
}
t.Run(theme.Name(), func(t *testing.T) {
content, err := os.ReadFile(filepath.Join(themesDir, theme.Name(), "mail", "repo", "release.tmpl"))
if err != nil {
t.Fatal(err)
}
tmpl, err := template.New("release").Funcs(TemplateFuncs).Parse(string(content))
if err != nil {
t.Fatal(err)
}
var rendered strings.Builder
if err := tmpl.Execute(&rendered, data); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), "release-notes.txt") || !strings.Contains(rendered.String(), "1.5 KiB") {
t.Fatal("release attachment or formatted size missing from email")
}
})
}
}
+39 -12
View File
@@ -22,7 +22,7 @@ type DevServerConfig struct {
}
// DevServer is a live-reload dev server with SSE-based browser notifications.
// It serves preview/ as static files, watches themes/ for .tmpl changes,
// It serves preview/ as static files, watches theme resources and snapshots,
// re-renders templates in-process, and pushes reload events to connected browsers.
type DevServer struct {
cfg DevServerConfig
@@ -46,7 +46,7 @@ func NewDevServer(cfg DevServerConfig) *DevServer {
func (s *DevServer) Start() error {
// Build initial rendered.js
if err := s.rebuildAll(); err != nil {
fmt.Fprintf(os.Stderr, "\033[33m[W]\033[0m [Builder] Initial render warning: %v\n", err)
return fmt.Errorf("initial preview build: %w", err)
}
// Start file watcher in background
@@ -61,11 +61,11 @@ func (s *DevServer) Start() error {
previewFS := http.FileServer(http.Dir(s.cfg.PreviewDir))
mux.Handle("/", previewFS)
addr := fmt.Sprintf(":%d", s.cfg.Port)
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
fmt.Printf("\033[32m[I]\033[0m [Server] http://localhost%s\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching themes/ for changes\n")
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint ws://localhost%s/events\n\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n")
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr)
return http.ListenAndServe(addr, mux)
}
@@ -131,19 +131,42 @@ func (s *DevServer) broadcast(typ string, payload map[string]interface{}) {
// --- File watcher (polling) ---
// scanMods scans themes/ for .tmpl files and returns a map of path → mod time.
// scanMods records theme resources, snapshot files and fixture modification times.
func (s *DevServer) scanMods() (map[string]time.Time, error) {
mods := make(map[string]time.Time)
err := filepath.Walk(s.cfg.ThemesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".tmpl") {
if !info.IsDir() && (strings.HasSuffix(info.Name(), ".json") || strings.HasSuffix(info.Name(), ".css") || strings.HasSuffix(info.Name(), ".html")) {
mods[path] = info.ModTime()
}
return nil
})
return mods, err
if err != nil {
return mods, err
}
for _, dir := range []string{filepath.Join(filepath.Dir(s.cfg.ThemesDir), "framework"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "build", "upstream"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data")} {
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
mods[path] = info.ModTime()
}
return nil
})
if err != nil {
return mods, err
}
}
lock := filepath.Join(filepath.Dir(s.cfg.ThemesDir), "gitea.lock.json")
if info, err := os.Stat(lock); err == nil {
mods[lock] = info.ModTime()
} else {
return mods, err
}
return mods, nil
}
// watchThemes polls the themes directory every 500ms and triggers rebuild on changes.
@@ -213,9 +236,13 @@ func (s *DevServer) rebuildAll() error {
start := time.Now()
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
result := RenderAll(s.cfg.ThemesDir, s.cfg.Config, nil)
if result == nil {
err := fmt.Errorf("no results produced")
cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json"))
if err != nil {
s.broadcast("error", map[string]interface{}{"message": err.Error()})
return err
}
result, err := RenderAll(s.cfg.ThemesDir, cfg, nil)
if err != nil {
s.broadcast("error", map[string]interface{}{"message": err.Error()})
return err
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gitea-mail-preview-qa",
"private": true,
"scripts": { "test": "node preview.cjs" },
"devDependencies": { "playwright": "1.62.1", "sharp": "0.35.5" }
}
+98
View File
@@ -0,0 +1,98 @@
// Optional browser QA: npm install in this directory, then npm test.
const { chromium } = require('playwright');
const sharp = require('sharp');
const fs = require('node:fs/promises');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const root = path.resolve(__dirname, '../..');
async function ready(page) {
await page.waitForFunction(() => document.getElementById('preview-frame').style.display === 'block' && document.getElementById('loading').style.display === 'none');
const frame = page.frameLocator('#preview-frame');
await frame.locator('body').waitFor();
return frame;
}
(async () => {
const browser = await chromium.launch({headless:true, executablePath:process.env.BROWSER_EXECUTABLE_PATH || undefined});
try {
const page = await browser.newPage({viewport:{width:1650,height:1050}});
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(pathToFileURL(path.join(root,'preview/index.html')).href);
await page.addStyleTag({content:'*{transition:none!important;animation:none!important}'});
await ready(page);
// Floating inspector is preview chrome, not part of the rendered email.
if (await page.locator('#panel-close').isVisible()) await page.click('#panel-close');
const languages = await page.locator('#sel-locale option').evaluateAll(options => options.map(o=>o.value));
const themes = await page.locator('#sel-theme option').evaluateAll(options => options.map(o=>o.value));
const types = await page.locator('#sel-template option').evaluateAll(options => options.map(o=>o.value));
const buttonColors = {aurora:'rgb(224, 245, 243)',bloom:'rgb(255, 255, 255)',ember:'rgb(255, 255, 255)',heritage:'rgb(255, 255, 255)',horizon:'rgb(255, 255, 255)',ink:'rgb(254, 253, 251)',mono:'rgb(0, 0, 0)',neon:'rgb(10, 10, 15)',terminal:'rgb(13, 17, 23)',terra:'rgb(255, 255, 255)'};
if (!languages.includes('en-US') || !languages.includes('zh-CN')) throw new Error('official languages missing');
for (const lang of languages) {
await page.selectOption('#sel-locale',lang);
const frame = await ready(page);
if (!(await frame.locator('body').textContent()).trim()) throw new Error('empty '+lang+' preview');
}
await page.selectOption('#sel-locale','pl-PL');
await page.selectOption('#sel-template','team_invite');
await ready(page);
await page.waitForFunction(()=>document.getElementById('locale-message')?.textContent.includes('[UPSTREAM-WARN]'));
await page.selectOption('#sel-locale','zh-CN');
await page.selectOption('#sel-locale','en-US');
await ready(page);
const screenshotDir = path.join(root,'build','screenshots');
await fs.mkdir(screenshotDir,{recursive:true});
for (const theme of themes) {
await page.selectOption('#sel-theme',theme);
await page.selectOption('#sel-template','register_notify');
await page.click('#btn-desk');
const desktop = await ready(page);
const cta = desktop.locator('.card-body .email-btn').first();
const color = await cta.evaluate(el=>getComputedStyle(el).color);
if (buttonColors[theme] && color!==buttonColors[theme]) throw new Error('CTA styling overridden: '+theme+' '+color);
const label = (await cta.textContent()).trim();
if (!label || /^https?:/i.test(label)) throw new Error('button displays URL: '+theme);
const fallback = desktop.locator('.email-link-url a').first();
if (await cta.getAttribute('href') !== await fallback.getAttribute('href')) throw new Error('button/fallback target mismatch: '+theme);
const logo = desktop.locator('img.email-logo');
if (await logo.count()) {
if (!(await logo.getAttribute('src')).startsWith('data:image/png;base64,')) throw new Error('preview logo is not generated official asset: '+theme);
if (!(await logo.evaluate(img=>img.complete && img.naturalWidth>0))) throw new Error('logo failed to load: '+theme);
}
// Capture the actual 600px email, not the wide preview canvas/inspector.
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'));
const box = await desktop.locator('.email-wrap').boundingBox();
if (!box) throw new Error('email frame missing: '+theme);
const png = await page.screenshot({clip:box,animations:'disabled'});
const background = await desktop.locator('body').evaluate(el=>getComputedStyle(el).backgroundColor);
const image = await sharp(png).extend({top:16,bottom:16,left:16,right:16,background}).resize({width:600}).png({compressionLevel:9}).toBuffer();
await fs.writeFile(path.join(screenshotDir,theme+'.png'),image);
for (const type of types) {
await page.selectOption('#sel-template',type);
await page.click('#btn-mob');
const frame = await ready(page);
const overflow = await frame.locator('html').evaluate(el=>el.scrollWidth > window.innerWidth + 1);
if (overflow) throw new Error('mobile overflow: '+theme+'/'+type);
}
}
if (errors.length) throw new Error(errors.join('\n'));
console.log('[PASS] file:// language bundles, warning, rapid switching, '+themes.length+' theme screenshots and mobile overflow');
// A running dev server can be included without making static QA depend on it.
if (process.env.PREVIEW_DEV_URL) {
await page.goto(process.env.PREVIEW_DEV_URL);
await ready(page);
await page.selectOption('#sel-locale','zh-CN');
await ready(page);
console.log('[PASS] HTTP preview and language switching');
}
if (process.argv.includes('--update-gallery')) {
for (const theme of themes) {
const image=await fs.readFile(path.join(screenshotDir,theme+'.png'));
if (image.length>50*1024) throw new Error('gallery image exceeds 50 KiB: '+theme);
await fs.writeFile(path.join(root,'docs','images',theme+'.png'),image);
}
console.log('[PASS] gallery screenshots updated');
}
} finally { await browser.close(); }
})().catch(error=>{console.error(error);process.exitCode=1;});
+2
View File
@@ -15,6 +15,8 @@ func main() {
Usage: "Gitea Mail Templates CLI — manage and preview email template themes",
UsageText: "tools <command> [args...] [--flags]",
Commands: []*cli.Command{
cmds.UpstreamCommand(),
cmds.BuildCommand(),
cmds.ListCommand(),
cmds.CreateCommand(),
cmds.DeleteCommand(),
+139
View File
@@ -0,0 +1,139 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"time"
)
func CacheDir(root string) string { return filepath.Join(root, "build", "upstream") }
func ReadLock(root string) (Lock, error) {
var lock Lock
b, err := os.ReadFile(filepath.Join(root, "gitea.lock.json"))
if err != nil {
return lock, err
}
if err = json.Unmarshal(b, &lock); err != nil {
return lock, err
}
return lock, lock.validateIdentity()
}
func LoadPinned(root string) (*Snapshot, error) {
lock, err := ReadLock(root)
if err != nil {
return nil, err
}
s, err := Load(CacheDir(root))
if err != nil {
return nil, err
}
if !reflect.DeepEqual(lock, s.Lock) {
return nil, fmt.Errorf("upstream cache differs from gitea.lock.json; run upstream prepare after reviewing the lock")
}
return s, nil
}
// Prepare fetches immutable commit files only if the cache is absent. A corrupt
// cache is an error, not permission to silently replace local evidence.
func Prepare(ctx context.Context, root string) (*Snapshot, error) {
return prepareFrom(ctx, root, "https://raw.githubusercontent.com/go-gitea/gitea/")
}
func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
lock, err := ReadLock(root)
if err != nil {
return nil, err
}
if _, err = os.Stat(filepath.Join(CacheDir(root), "lock.json")); err == nil {
return LoadPinned(root)
} else if !os.IsNotExist(err) {
return nil, err
}
client := &http.Client{Timeout: 45 * time.Second}
s := &Snapshot{Lock: lock, Files: map[string][]byte{}}
for name, hash := range lock.Files {
if !SafePath(name) {
return nil, fmt.Errorf("unsafe locked path %q", name)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawBase+lock.Commit+"/"+name, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GiteaMailTemplates-locked-cache")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", name, resp.StatusCode)
}
if Digest(b) != hash {
return nil, fmt.Errorf("download checksum mismatch: %s", name)
}
s.Files[name] = b
}
if err = s.validate(); err != nil {
return nil, err
}
dir := CacheDir(root)
if err = os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
return nil, err
}
stage, err := os.MkdirTemp(filepath.Dir(dir), ".upstream-cache-")
if err != nil {
return nil, err
}
defer os.RemoveAll(stage)
for name, b := range s.Files {
target := filepath.Join(stage, filepath.FromSlash(name))
if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return nil, err
}
if err = os.WriteFile(target, b, 0644); err != nil {
return nil, err
}
}
b, _ := json.MarshalIndent(lock, "", " ")
if err = os.WriteFile(filepath.Join(stage, "lock.json"), append(b, '\n'), 0644); err != nil {
return nil, err
}
if _, err = os.Stat(dir); err == nil {
return nil, fmt.Errorf("incomplete upstream cache exists at %s; inspect it before removing it", dir)
}
if err = os.Rename(stage, dir); err != nil {
return nil, err
}
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
return LoadPinned(root)
}
func SyncRoot(ctx context.Context, tag, root string) error {
if err := os.MkdirAll(filepath.Join(root, "build"), 0755); err != nil {
return err
}
if err := Sync(ctx, tag, CacheDir(root)); err != nil {
return err
}
s, err := Load(CacheDir(root))
if err != nil {
return err
}
b, err := json.MarshalIndent(s.Lock, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "gitea.lock.json"), append(b, '\n'), 0644)
}
+53
View File
@@ -0,0 +1,53 @@
package upstream
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrepareCacheIsPinnedAndReusableOffline(t *testing.T) {
s, err := LoadPinned(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
b, _ := json.Marshal(s.Lock)
if err = os.WriteFile(filepath.Join(root, "gitea.lock.json"), b, 0644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prefix := "/" + s.Lock.Commit + "/"
if !strings.HasPrefix(r.URL.Path, prefix) {
t.Errorf("unpinned request %s", r.URL.Path)
http.NotFound(w, r)
return
}
data, ok := s.Files[strings.TrimPrefix(r.URL.Path, prefix)]
if !ok {
http.NotFound(w, r)
return
}
w.Write(data)
}))
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
server.Close()
t.Fatal(err)
}
server.Close()
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
t.Fatalf("offline cache: %v", err)
}
path := filepath.Join(CacheDir(root), "templates", "mail", "base", "head.tmpl")
if err = os.WriteFile(path, []byte("corrupt"), 0644); err != nil {
t.Fatal(err)
}
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err == nil {
t.Fatal("corrupt cache was accepted")
}
}
+8
View File
@@ -0,0 +1,8 @@
package upstream
// ReviewedFormattingIssue is deliberately matched by language, key and exact
// upstream text. It cannot hide new defects after a snapshot update. Preview
// preserves Gitea's output and advertises this upstream defect to the user.
func ReviewedFormattingIssue(lang, key, value string) bool {
return lang == "pl-PL" && key == "mail.team_invite.text_1" && value == "%[1]zaprasza Cię do dołączenia do zespołu %[2]s w organizacji %[3]s."
}
+148
View File
@@ -0,0 +1,148 @@
package upstream
import (
"fmt"
"sort"
"text/template"
"text/template/parse"
)
// ReferencedKeys walks Go's template AST, including nested pipelines and both
// TrN keys. Dynamic translation keys require an explicit adapter review.
func ReferencedKeys(s *Snapshot) ([]string, error) {
fns := template.FuncMap{}
for _, name := range []string{"AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", "PathEscape", "SanitizeHTML"} {
fns[name] = func(...any) string { return "" }
}
keys := map[string]bool{}
var walk func(parse.Node) error
walk = func(n parse.Node) error {
if n == nil {
return nil
}
switch n := n.(type) {
case *parse.ListNode:
if n != nil {
for _, child := range n.Nodes {
if err := walk(child); err != nil {
return err
}
}
}
case *parse.ActionNode:
return walk(n.Pipe)
case *parse.PipeNode:
if n == nil {
return nil
}
for _, cmd := range n.Cmds {
if err := walk(cmd); err != nil {
return err
}
}
case *parse.CommandNode:
if len(n.Args) > 0 {
var ident []string
switch f := n.Args[0].(type) {
case *parse.FieldNode:
ident = f.Ident
case *parse.VariableNode:
ident = f.Ident
}
if len(ident) >= 2 && ident[len(ident)-2] == "locale" {
var positions []int
switch ident[len(ident)-1] {
case "Tr", "TrString":
positions = []int{1}
case "TrN":
positions = []int{2, 3}
}
for _, i := range positions {
if i >= len(n.Args) {
return fmt.Errorf("invalid translation call: %s", n)
}
key, ok := n.Args[i].(*parse.StringNode)
if !ok {
return fmt.Errorf("dynamic translation key needs review: %s", n)
}
keys[key.Text] = true
}
}
}
for _, arg := range n.Args {
if err := walk(arg); err != nil {
return err
}
}
case *parse.IfNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.RangeNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.WithNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.TemplateNode:
return walk(n.Pipe)
}
return nil
}
for p, data := range s.Templates {
t, err := template.New(p).Funcs(fns).Parse(string(data))
if err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
for _, t := range t.Templates() {
if t.Tree != nil {
if err := walk(t.Tree.Root); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
}
}
}
var result []string
for k := range keys {
result = append(result, k)
}
sort.Strings(result)
return result, nil
}
func ValidateKeys(s *Snapshot) error {
keys, err := ReferencedKeys(s)
if err != nil {
return err
}
for _, k := range keys {
if s.Locales["en-US"][k] == "" {
return fmt.Errorf("official English catalog is missing referenced key %q", k)
}
}
return nil
}
func MissingKeys(s *Snapshot) (map[string][]string, error) {
keys, err := ReferencedKeys(s)
if err != nil {
return nil, err
}
missing := map[string][]string{}
for lang, catalog := range s.Locales {
for _, k := range keys {
if catalog[k] == "" {
missing[lang] = append(missing[lang], k)
}
}
}
return missing, nil
}
+211
View File
@@ -0,0 +1,211 @@
// Package upstream loads and explicitly synchronizes the reviewed Gitea snapshot.
package upstream
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
type Lock struct {
Schema int `json:"schema"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Commit string `json:"commit"`
Files map[string]string `json:"files"`
}
type Snapshot struct {
Lock Lock
Files map[string][]byte
Templates map[string][]byte // mail/... paths, including shared partials
Locales map[string]map[string]string
}
// AdapterSources also binds the preview's formatting and rendering semantics to
// the upstream implementation reviewed during this migration. A changed source
// requires reviewing the adapter before accepting another snapshot.
var AdapterSources = map[string]string{
"modules/translation/translation.go": "4217cbd9beb79c0e1be52dd9a7d7705b9ff4becc2d9515d49236db126da0f7ee",
"modules/translation/i18n/localestore.go": "e55a7832d01753622a7d47e904de95e5aed7ad3535c59bf6ac3fec4346e40e9d",
"modules/translation/i18n/format.go": "f440236eaf1f73fe0a9aa36a2c036a4b34c6ab28c9e4cf971bef7fceee701cb4",
"modules/templates/mail.go": "fb8204b79f700f88c7c3f0da16e50c3d678a2216e62e98f712805ed1d42ab3a9",
}
func Digest(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
func SafePath(p string) bool {
return p != "." && fs.ValidPath(p) && !strings.ContainsAny(p, "\\:")
}
func Load(dir string) (*Snapshot, error) {
s, err := loadFiles(dir)
if err != nil {
return nil, err
}
return s, s.validate()
}
// Ownership/checksum validation is separate so explicitly syncing after an
// adapter review can replace an older, still intact snapshot.
func loadFiles(dir string) (*Snapshot, error) {
data, err := os.ReadFile(filepath.Join(dir, "lock.json"))
if err != nil {
return nil, fmt.Errorf("snapshot lock: %w (run upstream sync explicitly)", err)
}
s := &Snapshot{Files: map[string][]byte{}}
if err := json.Unmarshal(data, &s.Lock); err != nil {
return nil, err
}
if err := s.Lock.validateIdentity(); err != nil {
return nil, err
}
for p, expected := range s.Lock.Files {
if !SafePath(p) {
return nil, fmt.Errorf("unsafe snapshot path %q", p)
}
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
if err != nil {
return nil, err
}
if Digest(b) != expected {
return nil, fmt.Errorf("snapshot checksum mismatch: %s", p)
}
s.Files[p] = b
}
// Unlocked files must not silently become input to a build.
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("snapshot symlinks are unsupported: %s", p)
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(dir, p)
rel = filepath.ToSlash(rel)
if rel != "lock.json" {
if _, ok := s.Files[rel]; !ok {
return fmt.Errorf("unlocked snapshot file: %s", rel)
}
}
return nil
})
if err != nil {
return nil, err
}
return s, nil
}
func (lock Lock) validateIdentity() error {
if lock.Schema != 1 || lock.Repository != "https://github.com/go-gitea/gitea" || !validTag(lock.Tag) || len(lock.Commit) != 40 || len(lock.Files) == 0 {
return fmt.Errorf("invalid snapshot identity or unsupported version")
}
if _, err := hex.DecodeString(lock.Commit); err != nil {
return fmt.Errorf("invalid snapshot commit: %w", err)
}
return nil
}
func (s *Snapshot) validate() error {
if err := s.Lock.validateIdentity(); err != nil {
return err
}
for p, hash := range AdapterSources {
if Digest(s.Files[p]) != hash {
return fmt.Errorf("upstream adapter changed: %s; review preview semantics before syncing", p)
}
}
if len(s.Files["LICENSE"]) == 0 {
return fmt.Errorf("missing upstream license")
}
s.Templates = map[string][]byte{}
s.Locales = map[string]map[string]string{}
for p, b := range s.Files {
switch {
case p == "public/assets/img/favicon.png":
if len(b) == 0 {
return fmt.Errorf("missing preview favicon")
}
case strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl"):
s.Templates[strings.TrimPrefix(p, "templates/")] = b
case strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"):
locale := strings.TrimSuffix(strings.TrimPrefix(p, "options/locale/locale_"), ".json")
values, err := DecodeLocale(b)
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
s.Locales[locale] = values
default:
if p != "LICENSE" {
if _, ok := AdapterSources[p]; !ok {
return fmt.Errorf("unsupported snapshot file: %s", p)
}
}
}
}
if len(s.Entrypoints()) == 0 || len(s.Templates["mail/base/head.tmpl"]) == 0 || len(s.Templates["mail/base/footer.tmpl"]) == 0 || len(s.Locales["en-US"]) == 0 {
return fmt.Errorf("snapshot is missing entrypoints, shared partials, or English translations")
}
return ValidateKeys(s)
}
// DecodeLocale mirrors Gitea's flat or one-level nested JSON catalog layout.
func DecodeLocale(data []byte) (map[string]string, error) {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
result := map[string]string{}
for k, v := range raw {
switch v := v.(type) {
case string:
result[k] = v
case map[string]any:
for inner, value := range v {
str, ok := value.(string)
if !ok {
return nil, fmt.Errorf("unsupported translation value: %s.%s", k, inner)
}
result[k+"."+inner] = str
}
default:
return nil, fmt.Errorf("unsupported translation value: %s", k)
}
}
return result, nil
}
func (s *Snapshot) Entrypoints() []string {
var result []string
for p := range s.Templates {
if !strings.HasPrefix(p, "mail/base/") {
result = append(result, p)
}
}
sort.Strings(result)
return result
}
func (s *Snapshot) Languages() []string {
var langs []string
for l := range s.Locales {
langs = append(langs, l)
}
sort.Strings(langs)
return langs
}
func TemplateID(p string) string { return strings.TrimSuffix(path.Base(p), ".tmpl") }
+86
View File
@@ -0,0 +1,86 @@
package upstream
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSnapshotAndKeys(t *testing.T) {
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
if s.Lock.Tag != "v28.0.0" || len(s.Templates) != 13 || len(s.Locales) != 28 {
t.Fatalf("unexpected baseline %s", s.Lock.Tag)
}
keys, err := ReferencedKeys(s)
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, k := range keys {
found[k] = true
}
for _, k := range []string{"mail.issue.action.push_1", "mail.issue.action.push_n", "actions.runs.attempt"} {
if !found[k] {
t.Fatalf("missing key %s", k)
}
}
delete(s.Locales["en-US"], "actions.runs.attempt")
if ValidateKeys(s) == nil {
t.Fatal("missing English key accepted")
}
if !validTag("v28.0.0") || validTag("v1.27.3") || validTag("v28.1.0-rc1") {
t.Fatal("tag policy")
}
for _, p := range []string{"../x", "/x", "C:/x", "mail\\x"} {
if SafePath(p) {
t.Fatal("unsafe path accepted")
}
}
}
func TestSnapshotRejectsChecksumAndAdapterDrift(t *testing.T) {
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
for p, b := range s.Files {
target := filepath.Join(dir, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, b, 0644); err != nil {
t.Fatal(err)
}
}
writeLock := func() {
b, _ := json.Marshal(s.Lock)
if err := os.WriteFile(filepath.Join(dir, "lock.json"), b, 0644); err != nil {
t.Fatal(err)
}
}
writeLock()
p := "templates/mail/base/head.tmpl"
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
t.Fatal(err)
}
if _, err := Load(dir); err == nil {
t.Fatal("checksum mismatch accepted")
}
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), s.Files[p], 0644); err != nil {
t.Fatal(err)
}
p = "modules/translation/translation.go"
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
t.Fatal(err)
}
s.Lock.Files[p] = Digest([]byte("changed"))
writeLock()
if _, err := Load(dir); err == nil {
t.Fatal("changed translation adapter accepted despite updated checksum")
}
}
+164
View File
@@ -0,0 +1,164 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
var tagPattern = regexp.MustCompile(`^v([0-9]+)\.[0-9]+\.[0-9]+$`)
func validTag(tag string) bool {
m := tagPattern.FindStringSubmatch(tag)
if m == nil {
return false
}
major, err := strconv.Atoi(m[1])
return err == nil && major >= 28
}
// Sync downloads by resolved commit, validates in memory, then swaps the whole
// snapshot. Network/adapter failures leave the existing snapshot untouched.
func Sync(ctx context.Context, tag, dir string) error {
return syncFrom(ctx, tag, dir, "https://api.github.com/repos/go-gitea/gitea/", "https://raw.githubusercontent.com/go-gitea/gitea/")
}
func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
if !validTag(tag) {
return fmt.Errorf("expected a stable Gitea 28+ tag, e.g. v28.0.0")
}
client := &http.Client{Timeout: 45 * time.Second}
get := func(url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GiteaMailTemplates-snapshot")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
return b, err
}
b, err := get(api + "commits/" + tag)
if err != nil {
return err
}
var commit struct {
SHA string `json:"sha"`
Commit struct {
Tree struct {
SHA string `json:"sha"`
} `json:"tree"`
} `json:"commit"`
}
if err := json.Unmarshal(b, &commit); err != nil {
return err
}
b, err = get(api + "git/trees/" + commit.Commit.Tree.SHA + "?recursive=1")
if err != nil {
return err
}
var tree struct {
Truncated bool `json:"truncated"`
Tree []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"tree"`
}
if err := json.Unmarshal(b, &tree); err != nil {
return err
}
if tree.Truncated {
return fmt.Errorf("upstream tree is truncated")
}
s := &Snapshot{Lock: Lock{Schema: 1, Repository: "https://github.com/go-gitea/gitea", Tag: tag, Commit: commit.SHA, Files: map[string]string{}}, Files: map[string][]byte{}}
for _, item := range tree.Tree {
p := item.Path
_, adapter := AdapterSources[p]
selected := adapter || p == "LICENSE" || p == "public/assets/img/favicon.png" || (strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl")) || (strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"))
if !selected || item.Type != "blob" {
continue
}
b, err := get(raw + commit.SHA + "/" + p)
if err != nil {
return err
}
s.Files[p] = b
s.Lock.Files[p] = Digest(b)
}
if err := s.validate(); err != nil {
return err
}
abs, err := filepath.Abs(dir)
if err != nil {
return err
}
// Never replace a checkout or arbitrary non-snapshot directory.
if filepath.Base(abs) != "upstream" {
return fmt.Errorf("snapshot destination must be named upstream")
}
if entries, err := os.ReadDir(abs); err == nil && len(entries) > 0 {
if _, err := loadFiles(abs); err != nil {
return fmt.Errorf("refusing to replace invalid existing snapshot: %w", err)
}
}
stage, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
for p, data := range s.Files {
filename := filepath.Join(stage, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
return err
}
if err := os.WriteFile(filename, data, 0644); err != nil {
return err
}
}
b, err = json.MarshalIndent(s.Lock, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if err := os.WriteFile(filepath.Join(stage, "lock.json"), b, 0644); err != nil {
return err
}
if _, err := os.Stat(abs); err == nil {
backup, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-backup-")
if err != nil {
return err
}
if err := os.Remove(backup); err != nil {
return err
}
if err := os.Rename(abs, backup); err != nil {
return err
}
if err := os.Rename(stage, abs); err != nil {
_ = os.Rename(backup, abs)
return err
}
if err := os.RemoveAll(backup); err != nil {
return err
}
} else if err := os.Rename(stage, abs); err != nil {
return err
}
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
return nil
}