diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 899209874f..178d7a1363 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1767,6 +1767,9 @@ LEVEL = Info
;;
;; convert \r\n to \n for Sendmail
;SENDMAIL_CONVERT_CRLF = true
+;;
+;; convert links of attached images to inline images. Only for images hosted in this gitea instance.
+;EMBED_ATTACHMENT_IMAGES = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/modules/httplib/url.go b/modules/httplib/url.go
index f543c09190..5d5b64dc0c 100644
--- a/modules/httplib/url.go
+++ b/modules/httplib/url.go
@@ -102,25 +102,77 @@ func MakeAbsoluteURL(ctx context.Context, link string) string {
return GuessCurrentHostURL(ctx) + "/" + strings.TrimPrefix(link, "/")
}
-func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
+type urlType int
+
+const (
+ urlTypeGiteaAbsolute urlType = iota + 1 // "http://gitea/subpath"
+ urlTypeGiteaPageRelative // "/subpath"
+ urlTypeGiteaSiteRelative // "?key=val"
+ urlTypeUnknown // "http://other"
+)
+
+func detectURLRoutePath(ctx context.Context, s string) (routePath string, ut urlType) {
u, err := url.Parse(s)
if err != nil {
- return false
+ return "", urlTypeUnknown
}
+ cleanedPath := ""
if u.Path != "" {
- cleanedPath := util.PathJoinRelX(u.Path)
- if cleanedPath == "" || cleanedPath == "." {
- u.Path = "/"
- } else {
- u.Path = "/" + cleanedPath + "/"
- }
+ cleanedPath = util.PathJoinRelX(u.Path)
+ cleanedPath = util.Iif(cleanedPath == ".", "", "/"+cleanedPath)
}
if urlIsRelative(s, u) {
- return u.Path == "" || strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(setting.AppSubURL+"/"))
- }
- if u.Path == "" {
- u.Path = "/"
+ if u.Path == "" {
+ return "", urlTypeGiteaPageRelative
+ }
+ if strings.HasPrefix(strings.ToLower(cleanedPath+"/"), strings.ToLower(setting.AppSubURL+"/")) {
+ return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaSiteRelative
+ }
+ return "", urlTypeUnknown
}
+ u.Path = cleanedPath + "/"
urlLower := strings.ToLower(u.String())
- return strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) || strings.HasPrefix(urlLower, strings.ToLower(GuessCurrentAppURL(ctx)))
+ if strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) {
+ return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
+ }
+ guessedCurURL := GuessCurrentAppURL(ctx)
+ if strings.HasPrefix(urlLower, strings.ToLower(guessedCurURL)) {
+ return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
+ }
+ return "", urlTypeUnknown
+}
+
+func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
+ _, ut := detectURLRoutePath(ctx, s)
+ return ut != urlTypeUnknown
+}
+
+type GiteaSiteURL struct {
+ RoutePath string
+ OwnerName string
+ RepoName string
+ RepoSubPath string
+}
+
+func ParseGiteaSiteURL(ctx context.Context, s string) *GiteaSiteURL {
+ routePath, ut := detectURLRoutePath(ctx, s)
+ if ut == urlTypeUnknown || ut == urlTypeGiteaPageRelative {
+ return nil
+ }
+ ret := &GiteaSiteURL{RoutePath: routePath}
+ fields := strings.SplitN(strings.TrimPrefix(ret.RoutePath, "/"), "/", 3)
+
+ // TODO: now it only does a quick check for some known reserved paths, should do more strict checks in the future
+ if fields[0] == "attachments" {
+ return ret
+ }
+ if len(fields) < 2 {
+ return ret
+ }
+ ret.OwnerName = fields[0]
+ ret.RepoName = fields[1]
+ if len(fields) == 3 {
+ ret.RepoSubPath = "/" + fields[2]
+ }
+ return ret
}
diff --git a/modules/httplib/url_test.go b/modules/httplib/url_test.go
index cb8fac0a21..d57653646b 100644
--- a/modules/httplib/url_test.go
+++ b/modules/httplib/url_test.go
@@ -122,3 +122,26 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) {
assert.True(t, IsCurrentGiteaSiteURL(ctx, "https://user-host"))
assert.False(t, IsCurrentGiteaSiteURL(ctx, "https://forwarded-host"))
}
+
+func TestParseGiteaSiteURL(t *testing.T) {
+ defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
+ defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+ ctx := t.Context()
+ tests := []struct {
+ url string
+ exp *GiteaSiteURL
+ }{
+ {"http://localhost:3000/sub?k=v", &GiteaSiteURL{RoutePath: ""}},
+ {"http://localhost:3000/sub/", &GiteaSiteURL{RoutePath: ""}},
+ {"http://localhost:3000/sub/foo", &GiteaSiteURL{RoutePath: "/foo"}},
+ {"http://localhost:3000/sub/foo/bar", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
+ {"http://localhost:3000/sub/foo/bar/", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
+ {"http://localhost:3000/sub/attachments/bar", &GiteaSiteURL{RoutePath: "/attachments/bar"}},
+ {"http://localhost:3000/other", nil},
+ {"http://other/", nil},
+ }
+ for _, test := range tests {
+ su := ParseGiteaSiteURL(ctx, test.url)
+ assert.Equal(t, test.exp, su, "URL = %s", test.url)
+ }
+}
diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go
index 4c3dff6850..e79ff30447 100644
--- a/modules/setting/mailer.go
+++ b/modules/setting/mailer.go
@@ -13,7 +13,7 @@ import (
"code.gitea.io/gitea/modules/log"
- shellquote "github.com/kballard/go-shellquote"
+ "github.com/kballard/go-shellquote"
)
// Mailer represents mail service.
@@ -29,6 +29,9 @@ type Mailer struct {
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
OverrideHeader map[string][]string `ini:"-"`
+ // Embed attachment images as inline base64 img src attribute
+ EmbedAttachmentImages bool
+
// SMTP sender
Protocol string `ini:"PROTOCOL"`
SMTPAddr string `ini:"SMTP_ADDR"`
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index 7db259ac2c..f7e5b0c9f0 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -6,16 +6,26 @@ package mailer
import (
"bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
"html/template"
+ "io"
"mime"
"regexp"
"strings"
texttmpl "text/template"
+ repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/typesniffer"
sender_service "code.gitea.io/gitea/services/mailer/sender"
+
+ "golang.org/x/net/html"
)
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
@@ -44,6 +54,107 @@ func sanitizeSubject(subject string) string {
return mime.QEncoding.Encode("utf-8", string(runes))
}
+type mailAttachmentBase64Embedder struct {
+ doer *user_model.User
+ repo *repo_model.Repository
+ maxSize int64
+ estimateSize int64
+}
+
+func newMailAttachmentBase64Embedder(doer *user_model.User, repo *repo_model.Repository, maxSize int64) *mailAttachmentBase64Embedder {
+ return &mailAttachmentBase64Embedder{doer: doer, repo: repo, maxSize: maxSize}
+}
+
+func (b64embedder *mailAttachmentBase64Embedder) Base64InlineImages(ctx context.Context, body template.HTML) (template.HTML, error) {
+ doc, err := html.Parse(strings.NewReader(string(body)))
+ if err != nil {
+ return "", fmt.Errorf("html.Parse failed: %w", err)
+ }
+
+ b64embedder.estimateSize = int64(len(string(body)))
+
+ var processNode func(*html.Node)
+ processNode = func(n *html.Node) {
+ if n.Type == html.ElementNode {
+ if n.Data == "img" {
+ for i, attr := range n.Attr {
+ if attr.Key == "src" {
+ attachmentSrc := attr.Val
+ dataURI, err := b64embedder.AttachmentSrcToBase64DataURI(ctx, attachmentSrc)
+ if err != nil {
+ // Not an error, just skip. This is probably an image from outside the gitea instance.
+ log.Trace("Unable to embed attachment %q to mail body: %v", attachmentSrc, err)
+ } else {
+ n.Attr[i].Val = dataURI
+ }
+ break
+ }
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ processNode(c)
+ }
+ }
+
+ processNode(doc)
+
+ var buf bytes.Buffer
+ err = html.Render(&buf, doc)
+ if err != nil {
+ return "", fmt.Errorf("html.Render failed: %w", err)
+ }
+ return template.HTML(buf.String()), nil
+}
+
+func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ctx context.Context, attachmentSrc string) (string, error) {
+ parsedSrc := httplib.ParseGiteaSiteURL(ctx, attachmentSrc)
+ var attachmentUUID string
+ if parsedSrc != nil {
+ var ok bool
+ attachmentUUID, ok = strings.CutPrefix(parsedSrc.RoutePath, "/attachments/")
+ if !ok {
+ attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
+ }
+ if !ok {
+ return "", fmt.Errorf("not an attachment")
+ }
+ }
+ attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
+ if err != nil {
+ return "", err
+ }
+
+ if attachment.RepoID != b64embedder.repo.ID {
+ return "", fmt.Errorf("attachment does not belong to the repository")
+ }
+ if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
+ return "", fmt.Errorf("total embedded images exceed max limit")
+ }
+
+ fr, err := storage.Attachments.Open(attachment.RelativePath())
+ if err != nil {
+ return "", err
+ }
+ defer fr.Close()
+
+ lr := &io.LimitedReader{R: fr, N: b64embedder.maxSize + 1}
+ content, err := io.ReadAll(lr)
+ if err != nil {
+ return "", fmt.Errorf("LimitedReader ReadAll: %w", err)
+ }
+
+ mimeType := typesniffer.DetectContentType(content)
+ if !mimeType.IsImage() {
+ return "", fmt.Errorf("not an image")
+ }
+
+ encoded := base64.StdEncoding.EncodeToString(content)
+ dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType.GetMimeType(), encoded)
+ b64embedder.estimateSize += int64(len(dataURI))
+ return dataURI, nil
+}
+
func fromDisplayName(u *user_model.User) string {
if setting.MailService.FromDisplayNameFormatTemplate != nil {
var ctx bytes.Buffer
diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go
index 23ca4c3f15..85fe7c1f9a 100644
--- a/services/mailer/mail_issue_common.go
+++ b/services/mailer/mail_issue_common.go
@@ -25,6 +25,10 @@ import (
"code.gitea.io/gitea/services/mailer/token"
)
+// maxEmailBodySize is the approximate maximum size of an email body in bytes
+// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
+const maxEmailBodySize = 9_000_000
+
func fallbackMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
@@ -64,12 +68,20 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
// This is the body of the new issue or comment, not the mail body
rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
- body, err := markdown.RenderString(rctx,
- ctx.Content)
+ body, err := markdown.RenderString(rctx, ctx.Content)
if err != nil {
return nil, err
}
+ if setting.MailService.EmbedAttachmentImages {
+ attEmbedder := newMailAttachmentBase64Embedder(ctx.Doer, ctx.Issue.Repo, maxEmailBodySize)
+ bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
+ if err != nil {
+ log.Error("Failed to embed images in mail body: %v", err)
+ } else {
+ body = bodyAfterEmbedding
+ }
+ }
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
if actName != "new" {
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index 1860257e2e..85ee345545 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
+ "encoding/base64"
"fmt"
"html/template"
"io"
@@ -23,9 +24,12 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/services/attachment"
sender_service "code.gitea.io/gitea/services/mailer/sender"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
const subjectTpl = `
@@ -53,22 +57,44 @@ const bodyTpl = `
func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment) {
assert.NoError(t, unittest.PrepareTestDatabase())
- mailService := setting.Mailer{
- From: "test@gitea.com",
- }
-
- setting.MailService = &mailService
+ setting.MailService = &setting.Mailer{From: "test@gitea.com"}
setting.Domain = "localhost"
+ setting.AppURL = "https://try.gitea.io/"
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, Owner: doer})
issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1, Repo: repo, Poster: doer})
- assert.NoError(t, issue.LoadRepo(db.DefaultContext))
comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue})
+ require.NoError(t, issue.LoadRepo(db.DefaultContext))
return doer, repo, issue, comment
}
-func TestComposeIssueCommentMessage(t *testing.T) {
+func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, att1, att2 *repo_model.Attachment) {
+ user, repo, issue, comment := prepareMailerTest(t)
+ setting.MailService.EmbedAttachmentImages = true
+
+ att1, err := attachment.NewAttachment(t.Context(), &repo_model.Attachment{
+ RepoID: repo.ID,
+ IssueID: issue.ID,
+ UploaderID: user.ID,
+ CommentID: comment.ID,
+ Name: "test.png",
+ }, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")), 8)
+ require.NoError(t, err)
+
+ att2, err = attachment.NewAttachment(t.Context(), &repo_model.Attachment{
+ RepoID: repo.ID,
+ IssueID: issue.ID,
+ UploaderID: user.ID,
+ CommentID: comment.ID,
+ Name: "test.png",
+ }, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"+strings.Repeat("\x00", 1024))), 8+1024)
+ require.NoError(t, err)
+
+ return user, repo, issue, att1, att2
+}
+
+func TestComposeIssueComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
markup.Init(&markup.RenderHelperFuncs{
@@ -109,7 +135,8 @@ func TestComposeIssueCommentMessage(t *testing.T) {
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 2) // url + mailto
var buf bytes.Buffer
- gomailMsg.WriteTo(&buf)
+ _, err = gomailMsg.WriteTo(&buf)
+ require.NoError(t, err)
b, err := io.ReadAll(quotedprintable.NewReader(&buf))
assert.NoError(t, err)
@@ -404,9 +431,9 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
}
func TestFromDisplayName(t *testing.T) {
- template, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
+ tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
assert.NoError(t, err)
- setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+ setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
defer func() { setting.MailService = nil }()
tests := []struct {
@@ -435,9 +462,9 @@ func TestFromDisplayName(t *testing.T) {
}
t.Run("template with all available vars", func(t *testing.T) {
- template, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
+ tmpl, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
assert.NoError(t, err)
- setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+ setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
oldAppName := setting.AppName
setting.AppName = "Code IT"
oldDomain := setting.Domain
@@ -450,3 +477,72 @@ func TestFromDisplayName(t *testing.T) {
assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
})
}
+
+func TestEmbedBase64Images(t *testing.T) {
+ user, repo, issue, att1, att2 := prepareMailerBase64Test(t)
+ ctx := &mailCommentContext{Context: t.Context(), Issue: issue, Doer: user}
+
+ imgExternalURL := "https://via.placeholder.com/10"
+ imgExternalImg := fmt.Sprintf(`
`, imgExternalURL)
+
+ att1URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att1.UUID
+ att1Img := fmt.Sprintf(`
`, att1URL)
+ att1Base64 := "data:image/png;base64,iVBORw0KGgo="
+ att1ImgBase64 := fmt.Sprintf(`
`, att1Base64)
+
+ att2URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att2.UUID
+ att2Img := fmt.Sprintf(`
`, att2URL)
+ att2File, err := storage.Attachments.Open(att2.RelativePath())
+ require.NoError(t, err)
+ defer att2File.Close()
+ att2Bytes, err := io.ReadAll(att2File)
+ require.NoError(t, err)
+ require.Greater(t, len(att2Bytes), 1024)
+ att2Base64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(att2Bytes)
+ att2ImgBase64 := fmt.Sprintf(`
`, att2Base64)
+
+ t.Run("ComposeMessage", func(t *testing.T) {
+ subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
+ bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
+
+ issue.Content = fmt.Sprintf(`MSG-BEFORE MSG-AFTER`, att1.UUID)
+ require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
+
+ recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
+ msgs, err := composeIssueCommentMessages(&mailCommentContext{
+ Context: t.Context(),
+ Issue: issue,
+ Doer: user,
+ ActionType: activities_model.ActionCreateIssue,
+ Content: issue.Content,
+ }, "en-US", recipients, false, "issue create")
+ require.NoError(t, err)
+
+ mailBody := msgs[0].Body
+ assert.Regexp(t, `MSG-BEFORE ]+>
MSG-AFTER`, mailBody)
+ })
+
+ t.Run("EmbedInstanceImageSkipExternalImage", func(t *testing.T) {
+ mailBody := "Test1
" + imgExternalImg + "Test2
" + att1Img + "Test3
"
+ expectedMailBody := "Test1
" + imgExternalImg + "Test2
" + att1ImgBase64 + "Test3
"
+ b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
+ resultMailBody, err := b64embedder.Base64InlineImages(ctx, template.HTML(mailBody))
+ require.NoError(t, err)
+ assert.Equal(t, expectedMailBody, string(resultMailBody))
+ })
+
+ t.Run("LimitedEmailBodySize", func(t *testing.T) {
+ mailBody := fmt.Sprintf("%s%s", att1Img, att2Img)
+ b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
+ resultMailBody, err := b64embedder.Base64InlineImages(ctx, template.HTML(mailBody))
+ require.NoError(t, err)
+ expected := fmt.Sprintf("%s%s", att1ImgBase64, att2Img)
+ assert.Equal(t, expected, string(resultMailBody))
+
+ b64embedder = newMailAttachmentBase64Embedder(user, repo, 4096)
+ resultMailBody, err = b64embedder.Base64InlineImages(ctx, template.HTML(mailBody))
+ require.NoError(t, err)
+ expected = fmt.Sprintf("%s%s", att1ImgBase64, att2ImgBase64)
+ assert.Equal(t, expected, string(resultMailBody))
+ })
+}