Files
Gitea/routers/web/devtest/mail_preview.go
T
Jacky 08c5fcafd3 fix(mail): preserve outer spacing in notification emails (#40104)
Remove the `body {margin: 0; padding: 0}` reset from
https://github.com/go-gitea/gitea/pull/38396, which left HTML mails
flush against the edge in Apple Mail and Thunderbird. The devtest
preview stops injecting its own padding, so it matches delivered mails.

These clients render the mail as a document and apply the default 8px
body margin again, as with GitHub and GitLab notifications. Webmail
clients replace `<body>` and keep their own inset.

Fixes https://github.com/go-gitea/gitea/issues/40103
Assisted-by: Codex:GPT-6.1, Claude:Opus 5.5
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 10:02:08 +01:00

102 lines
3.3 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package devtest
import (
"errors"
"io/fs"
"net/http"
"regexp"
"strings"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/mailer"
"go.yaml.in/yaml/v4"
)
var mailDarkSchemeQuery = regexp.MustCompile(`@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)`)
func mailPreviewMockData(tmplName string) (map[string]any, error) {
mockData := map[string]any{}
mockDataContent, err := templates.AssetFS().ReadFile(tmplName + ".devtest.yml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return mockData, nil
}
return nil, err
}
return mockData, yaml.Unmarshal(mockDataContent, &mockData)
}
func MailPreviewRender(ctx *context.Context) {
tmplName := ctx.PathParam("*")
mockData, err := mailPreviewMockData(tmplName)
if err != nil {
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
return
}
mockData["locale"] = ctx.Locale
var mailBody strings.Builder
if err := mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tmplName, mockData); err != nil {
http.Error(ctx.Resp, err.Error(), http.StatusInternalServerError)
return
}
body := mailBody.String()
// emulate mail clients, which resolve "cid:" URIs to the mail's inline attachments
body = strings.ReplaceAll(body, `src="cid:`, `src="`+setting.AppSubURL+`/devtest/mail-preview-embed/`)
// a page can force "color-scheme" on an embedded document but never "prefers-color-scheme"
if scheme := ctx.FormString("scheme"); scheme == "light" || scheme == "dark" {
body = mailDarkSchemeQuery.ReplaceAllString(body, util.Iif(scheme == "dark", "@media all", "@media not all"))
body = strings.Replace(body, "</head>", "<style>:root {color-scheme: "+scheme+"}</style></head>", 1)
}
// fragment templates like "mail/base/head" would be sniffed as text/plain otherwise
ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = ctx.Resp.Write([]byte(body))
}
func MailPreviewEmbed(ctx *context.Context) {
content, err := mailer.LoadMailIcon(ctx.PathParam("*"))
if err != nil {
ctx.NotFound(err)
return
}
ctx.Resp.Header().Set("Content-Type", "image/png")
_, _ = ctx.Resp.Write(content)
}
func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
subject := "(default subject)"
if mockData, err := mailPreviewMockData(tmplName); err == nil {
if mockSubject, ok := mockData["Subject"].(string); ok {
subject = util.IfZero(mockSubject, subject)
}
}
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
if tmplSubject != nil {
var buf strings.Builder
err := tmplSubject.Execute(&buf, nil)
if err != nil {
subject = "ERROR: " + err.Error()
} else {
subject = util.IfZero(buf.String(), subject)
}
}
ctx.Data["RenderMailSubject"] = subject
ctx.Data["RenderMailTemplateName"] = tmplName
}
func MailPreview(ctx *context.Context) {
ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames
tmplName := ctx.FormString("tmpl")
if tmplName != "" {
prepareMailPreviewRender(ctx, tmplName)
}
ctx.HTML(http.StatusOK, "devtest/mail-preview")
}