Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email option to embed images as base64 instead of link #32061

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
3 changes: 3 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
78 changes: 65 additions & 13 deletions modules/httplib/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
23 changes: 23 additions & 0 deletions modules/httplib/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 4 additions & 1 deletion modules/setting/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"`
Expand Down
111 changes: 111 additions & 0 deletions services/mailer/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions services/mailer/mail_issue_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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" {
Expand Down
Loading