Skip to content

Commit 7cdde20

Browse files
Email option to embed images as base64 instead of link (#32061)
ref: #15081 ref: #14037 Documentation: https://gitea.com/gitea/docs/pulls/69 # Example Content: ![image](https://github.com/user-attachments/assets/e73ebfbe-e329-40f6-9c4a-f73832bbb181) Result in Email: ![image](https://github.com/user-attachments/assets/55b7019f-e17a-46c3-a374-3b4769d5c2d6) Result with source code: (first image is external image, 2nd is now embedded) ![image](https://github.com/user-attachments/assets/8e2804a1-580f-4a69-adcb-cc5d16f7da81) --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent f0f1041 commit 7cdde20

File tree

7 files changed

+328
-28
lines changed

7 files changed

+328
-28
lines changed

custom/conf/app.example.ini

+3
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,9 @@ LEVEL = Info
17671767
;;
17681768
;; convert \r\n to \n for Sendmail
17691769
;SENDMAIL_CONVERT_CRLF = true
1770+
;;
1771+
;; convert links of attached images to inline images. Only for images hosted in this gitea instance.
1772+
;EMBED_ATTACHMENT_IMAGES = false
17701773

17711774
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
17721775
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

modules/httplib/url.go

+65-13
Original file line numberDiff line numberDiff line change
@@ -102,25 +102,77 @@ func MakeAbsoluteURL(ctx context.Context, link string) string {
102102
return GuessCurrentHostURL(ctx) + "/" + strings.TrimPrefix(link, "/")
103103
}
104104

105-
func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
105+
type urlType int
106+
107+
const (
108+
urlTypeGiteaAbsolute urlType = iota + 1 // "http://gitea/subpath"
109+
urlTypeGiteaPageRelative // "/subpath"
110+
urlTypeGiteaSiteRelative // "?key=val"
111+
urlTypeUnknown // "http://other"
112+
)
113+
114+
func detectURLRoutePath(ctx context.Context, s string) (routePath string, ut urlType) {
106115
u, err := url.Parse(s)
107116
if err != nil {
108-
return false
117+
return "", urlTypeUnknown
109118
}
119+
cleanedPath := ""
110120
if u.Path != "" {
111-
cleanedPath := util.PathJoinRelX(u.Path)
112-
if cleanedPath == "" || cleanedPath == "." {
113-
u.Path = "/"
114-
} else {
115-
u.Path = "/" + cleanedPath + "/"
116-
}
121+
cleanedPath = util.PathJoinRelX(u.Path)
122+
cleanedPath = util.Iif(cleanedPath == ".", "", "/"+cleanedPath)
117123
}
118124
if urlIsRelative(s, u) {
119-
return u.Path == "" || strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(setting.AppSubURL+"/"))
120-
}
121-
if u.Path == "" {
122-
u.Path = "/"
125+
if u.Path == "" {
126+
return "", urlTypeGiteaPageRelative
127+
}
128+
if strings.HasPrefix(strings.ToLower(cleanedPath+"/"), strings.ToLower(setting.AppSubURL+"/")) {
129+
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaSiteRelative
130+
}
131+
return "", urlTypeUnknown
123132
}
133+
u.Path = cleanedPath + "/"
124134
urlLower := strings.ToLower(u.String())
125-
return strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) || strings.HasPrefix(urlLower, strings.ToLower(GuessCurrentAppURL(ctx)))
135+
if strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) {
136+
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
137+
}
138+
guessedCurURL := GuessCurrentAppURL(ctx)
139+
if strings.HasPrefix(urlLower, strings.ToLower(guessedCurURL)) {
140+
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
141+
}
142+
return "", urlTypeUnknown
143+
}
144+
145+
func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
146+
_, ut := detectURLRoutePath(ctx, s)
147+
return ut != urlTypeUnknown
148+
}
149+
150+
type GiteaSiteURL struct {
151+
RoutePath string
152+
OwnerName string
153+
RepoName string
154+
RepoSubPath string
155+
}
156+
157+
func ParseGiteaSiteURL(ctx context.Context, s string) *GiteaSiteURL {
158+
routePath, ut := detectURLRoutePath(ctx, s)
159+
if ut == urlTypeUnknown || ut == urlTypeGiteaPageRelative {
160+
return nil
161+
}
162+
ret := &GiteaSiteURL{RoutePath: routePath}
163+
fields := strings.SplitN(strings.TrimPrefix(ret.RoutePath, "/"), "/", 3)
164+
165+
// TODO: now it only does a quick check for some known reserved paths, should do more strict checks in the future
166+
if fields[0] == "attachments" {
167+
return ret
168+
}
169+
if len(fields) < 2 {
170+
return ret
171+
}
172+
ret.OwnerName = fields[0]
173+
ret.RepoName = fields[1]
174+
if len(fields) == 3 {
175+
ret.RepoSubPath = "/" + fields[2]
176+
}
177+
return ret
126178
}

modules/httplib/url_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,26 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) {
122122
assert.True(t, IsCurrentGiteaSiteURL(ctx, "https://user-host"))
123123
assert.False(t, IsCurrentGiteaSiteURL(ctx, "https://forwarded-host"))
124124
}
125+
126+
func TestParseGiteaSiteURL(t *testing.T) {
127+
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
128+
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
129+
ctx := t.Context()
130+
tests := []struct {
131+
url string
132+
exp *GiteaSiteURL
133+
}{
134+
{"http://localhost:3000/sub?k=v", &GiteaSiteURL{RoutePath: ""}},
135+
{"http://localhost:3000/sub/", &GiteaSiteURL{RoutePath: ""}},
136+
{"http://localhost:3000/sub/foo", &GiteaSiteURL{RoutePath: "/foo"}},
137+
{"http://localhost:3000/sub/foo/bar", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
138+
{"http://localhost:3000/sub/foo/bar/", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
139+
{"http://localhost:3000/sub/attachments/bar", &GiteaSiteURL{RoutePath: "/attachments/bar"}},
140+
{"http://localhost:3000/other", nil},
141+
{"http://other/", nil},
142+
}
143+
for _, test := range tests {
144+
su := ParseGiteaSiteURL(ctx, test.url)
145+
assert.Equal(t, test.exp, su, "URL = %s", test.url)
146+
}
147+
}

modules/setting/mailer.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
"code.gitea.io/gitea/modules/log"
1515

16-
shellquote "github.com/kballard/go-shellquote"
16+
"github.com/kballard/go-shellquote"
1717
)
1818

1919
// Mailer represents mail service.
@@ -29,6 +29,9 @@ type Mailer struct {
2929
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
3030
OverrideHeader map[string][]string `ini:"-"`
3131

32+
// Embed attachment images as inline base64 img src attribute
33+
EmbedAttachmentImages bool
34+
3235
// SMTP sender
3336
Protocol string `ini:"PROTOCOL"`
3437
SMTPAddr string `ini:"SMTP_ADDR"`

services/mailer/mail.go

+111
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,26 @@ package mailer
66

77
import (
88
"bytes"
9+
"context"
10+
"encoding/base64"
11+
"fmt"
912
"html/template"
13+
"io"
1014
"mime"
1115
"regexp"
1216
"strings"
1317
texttmpl "text/template"
1418

19+
repo_model "code.gitea.io/gitea/models/repo"
1520
user_model "code.gitea.io/gitea/models/user"
21+
"code.gitea.io/gitea/modules/httplib"
1622
"code.gitea.io/gitea/modules/log"
1723
"code.gitea.io/gitea/modules/setting"
24+
"code.gitea.io/gitea/modules/storage"
25+
"code.gitea.io/gitea/modules/typesniffer"
1826
sender_service "code.gitea.io/gitea/services/mailer/sender"
27+
28+
"golang.org/x/net/html"
1929
)
2030

2131
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
@@ -44,6 +54,107 @@ func sanitizeSubject(subject string) string {
4454
return mime.QEncoding.Encode("utf-8", string(runes))
4555
}
4656

57+
type mailAttachmentBase64Embedder struct {
58+
doer *user_model.User
59+
repo *repo_model.Repository
60+
maxSize int64
61+
estimateSize int64
62+
}
63+
64+
func newMailAttachmentBase64Embedder(doer *user_model.User, repo *repo_model.Repository, maxSize int64) *mailAttachmentBase64Embedder {
65+
return &mailAttachmentBase64Embedder{doer: doer, repo: repo, maxSize: maxSize}
66+
}
67+
68+
func (b64embedder *mailAttachmentBase64Embedder) Base64InlineImages(ctx context.Context, body template.HTML) (template.HTML, error) {
69+
doc, err := html.Parse(strings.NewReader(string(body)))
70+
if err != nil {
71+
return "", fmt.Errorf("html.Parse failed: %w", err)
72+
}
73+
74+
b64embedder.estimateSize = int64(len(string(body)))
75+
76+
var processNode func(*html.Node)
77+
processNode = func(n *html.Node) {
78+
if n.Type == html.ElementNode {
79+
if n.Data == "img" {
80+
for i, attr := range n.Attr {
81+
if attr.Key == "src" {
82+
attachmentSrc := attr.Val
83+
dataURI, err := b64embedder.AttachmentSrcToBase64DataURI(ctx, attachmentSrc)
84+
if err != nil {
85+
// Not an error, just skip. This is probably an image from outside the gitea instance.
86+
log.Trace("Unable to embed attachment %q to mail body: %v", attachmentSrc, err)
87+
} else {
88+
n.Attr[i].Val = dataURI
89+
}
90+
break
91+
}
92+
}
93+
}
94+
}
95+
for c := n.FirstChild; c != nil; c = c.NextSibling {
96+
processNode(c)
97+
}
98+
}
99+
100+
processNode(doc)
101+
102+
var buf bytes.Buffer
103+
err = html.Render(&buf, doc)
104+
if err != nil {
105+
return "", fmt.Errorf("html.Render failed: %w", err)
106+
}
107+
return template.HTML(buf.String()), nil
108+
}
109+
110+
func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ctx context.Context, attachmentSrc string) (string, error) {
111+
parsedSrc := httplib.ParseGiteaSiteURL(ctx, attachmentSrc)
112+
var attachmentUUID string
113+
if parsedSrc != nil {
114+
var ok bool
115+
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RoutePath, "/attachments/")
116+
if !ok {
117+
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
118+
}
119+
if !ok {
120+
return "", fmt.Errorf("not an attachment")
121+
}
122+
}
123+
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
124+
if err != nil {
125+
return "", err
126+
}
127+
128+
if attachment.RepoID != b64embedder.repo.ID {
129+
return "", fmt.Errorf("attachment does not belong to the repository")
130+
}
131+
if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
132+
return "", fmt.Errorf("total embedded images exceed max limit")
133+
}
134+
135+
fr, err := storage.Attachments.Open(attachment.RelativePath())
136+
if err != nil {
137+
return "", err
138+
}
139+
defer fr.Close()
140+
141+
lr := &io.LimitedReader{R: fr, N: b64embedder.maxSize + 1}
142+
content, err := io.ReadAll(lr)
143+
if err != nil {
144+
return "", fmt.Errorf("LimitedReader ReadAll: %w", err)
145+
}
146+
147+
mimeType := typesniffer.DetectContentType(content)
148+
if !mimeType.IsImage() {
149+
return "", fmt.Errorf("not an image")
150+
}
151+
152+
encoded := base64.StdEncoding.EncodeToString(content)
153+
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType.GetMimeType(), encoded)
154+
b64embedder.estimateSize += int64(len(dataURI))
155+
return dataURI, nil
156+
}
157+
47158
func fromDisplayName(u *user_model.User) string {
48159
if setting.MailService.FromDisplayNameFormatTemplate != nil {
49160
var ctx bytes.Buffer

services/mailer/mail_issue_common.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import (
2525
"code.gitea.io/gitea/services/mailer/token"
2626
)
2727

28+
// maxEmailBodySize is the approximate maximum size of an email body in bytes
29+
// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
30+
const maxEmailBodySize = 9_000_000
31+
2832
func fallbackMailSubject(issue *issues_model.Issue) string {
2933
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
3034
}
@@ -64,12 +68,20 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
6468

6569
// This is the body of the new issue or comment, not the mail body
6670
rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
67-
body, err := markdown.RenderString(rctx,
68-
ctx.Content)
71+
body, err := markdown.RenderString(rctx, ctx.Content)
6972
if err != nil {
7073
return nil, err
7174
}
7275

76+
if setting.MailService.EmbedAttachmentImages {
77+
attEmbedder := newMailAttachmentBase64Embedder(ctx.Doer, ctx.Issue.Repo, maxEmailBodySize)
78+
bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
79+
if err != nil {
80+
log.Error("Failed to embed images in mail body: %v", err)
81+
} else {
82+
body = bodyAfterEmbedding
83+
}
84+
}
7385
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
7486

7587
if actName != "new" {

0 commit comments

Comments
 (0)