Skip to content

Commit b2db1a7

Browse files
committed
feat: Add support for embedding images in emails as base64 data URIs
1 parent fc1b383 commit b2db1a7

File tree

3 files changed

+141
-10
lines changed

3 files changed

+141
-10
lines changed

custom/conf/app.example.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,12 @@ 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+
;BASE64_EMBED_IMAGES = false
1773+
;;
1774+
;; The maximum size of sum of all images in a single email. Default is 9.5MB
1775+
;BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL = 9961472
17701776

17711777
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
17721778
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

modules/setting/mailer.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,23 @@ 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.
2020
type Mailer struct {
2121
// Mailer
22-
Name string `ini:"NAME"`
23-
From string `ini:"FROM"`
24-
EnvelopeFrom string `ini:"ENVELOPE_FROM"`
25-
OverrideEnvelopeFrom bool `ini:"-"`
26-
FromName string `ini:"-"`
27-
FromEmail string `ini:"-"`
28-
SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"`
29-
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
30-
OverrideHeader map[string][]string `ini:"-"`
22+
Name string `ini:"NAME"`
23+
From string `ini:"FROM"`
24+
EnvelopeFrom string `ini:"ENVELOPE_FROM"`
25+
OverrideEnvelopeFrom bool `ini:"-"`
26+
FromName string `ini:"-"`
27+
FromEmail string `ini:"-"`
28+
SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"`
29+
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
30+
OverrideHeader map[string][]string `ini:"-"`
31+
Base64EmbedImages bool `ini:"BASE64_EMBED_IMAGES"`
32+
Base64EmbedImagesMaxSizePerEmail int64 `ini:"BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL"`
3133

3234
// SMTP sender
3335
Protocol string `ini:"PROTOCOL"`
@@ -150,6 +152,8 @@ func loadMailerFrom(rootCfg ConfigProvider) {
150152
sec.Key("SENDMAIL_TIMEOUT").MustDuration(5 * time.Minute)
151153
sec.Key("SENDMAIL_CONVERT_CRLF").MustBool(true)
152154
sec.Key("FROM").MustString(sec.Key("USER").String())
155+
sec.Key("BASE64_EMBED_IMAGES").MustBool(false)
156+
sec.Key("BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL").MustInt64(9.5 * 1024 * 1024)
153157

154158
// Now map the values on to the MailService
155159
MailService = &Mailer{}

services/mailer/mail.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ package mailer
77
import (
88
"bytes"
99
"context"
10+
"encoding/base64"
1011
"fmt"
1112
"html/template"
13+
"io"
1214
"mime"
15+
"net/http"
1316
"regexp"
1417
"strconv"
1518
"strings"
@@ -18,19 +21,24 @@ import (
1821

1922
activities_model "code.gitea.io/gitea/models/activities"
2023
issues_model "code.gitea.io/gitea/models/issues"
24+
access_model "code.gitea.io/gitea/models/perm/access"
2125
"code.gitea.io/gitea/models/renderhelper"
2226
repo_model "code.gitea.io/gitea/models/repo"
27+
"code.gitea.io/gitea/models/unit"
2328
user_model "code.gitea.io/gitea/models/user"
2429
"code.gitea.io/gitea/modules/emoji"
2530
"code.gitea.io/gitea/modules/log"
2631
"code.gitea.io/gitea/modules/markup/markdown"
2732
"code.gitea.io/gitea/modules/setting"
33+
"code.gitea.io/gitea/modules/storage"
2834
"code.gitea.io/gitea/modules/templates"
2935
"code.gitea.io/gitea/modules/timeutil"
3036
"code.gitea.io/gitea/modules/translation"
3137
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
3238
sender_service "code.gitea.io/gitea/services/mailer/sender"
3339
"code.gitea.io/gitea/services/mailer/token"
40+
41+
"golang.org/x/net/html"
3442
)
3543

3644
const (
@@ -228,6 +236,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
228236
return nil, err
229237
}
230238

239+
if setting.MailService.Base64EmbedImages {
240+
bodyStr := string(body)
241+
bodyStr, err = Base64InlineImages(bodyStr, ctx)
242+
if err != nil {
243+
return nil, err
244+
}
245+
body = template.HTML(bodyStr)
246+
}
247+
231248
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
232249

233250
if actName != "new" {
@@ -359,6 +376,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
359376
return msgs, nil
360377
}
361378

379+
func Base64InlineImages(body string, ctx *mailCommentContext) (string, error) {
380+
doc, err := html.Parse(strings.NewReader(body))
381+
if err != nil {
382+
log.Error("Failed to parse HTML body: %v", err)
383+
return "", err
384+
}
385+
386+
var totalEmbeddedImagesSize int64
387+
388+
var processNode func(*html.Node)
389+
processNode = func(n *html.Node) {
390+
if n.Type == html.ElementNode {
391+
if n.Data == "img" {
392+
for i, attr := range n.Attr {
393+
if attr.Key == "src" {
394+
attachmentPath := attr.Val
395+
dataURI, err := AttachmentSrcToBase64DataURI(attachmentPath, ctx, &totalEmbeddedImagesSize)
396+
if err != nil {
397+
log.Trace("attachmentSrcToDataURI not possible: %v", err) // Not an error, just skip. This is probably an image from outside the gitea instance.
398+
continue
399+
}
400+
log.Trace("Old value of src attribute: %s, new value (first 100 characters): %s", attr.Val, dataURI[:100])
401+
n.Attr[i].Val = dataURI
402+
break
403+
}
404+
}
405+
}
406+
}
407+
408+
for c := n.FirstChild; c != nil; c = c.NextSibling {
409+
processNode(c)
410+
}
411+
}
412+
413+
processNode(doc)
414+
415+
var buf bytes.Buffer
416+
err = html.Render(&buf, doc)
417+
if err != nil {
418+
log.Error("Failed to render modified HTML: %v", err)
419+
return "", err
420+
}
421+
return buf.String(), nil
422+
}
423+
424+
func AttachmentSrcToBase64DataURI(attachmentPath string, ctx *mailCommentContext, totalEmbeddedImagesSize *int64) (string, error) {
425+
if !strings.HasPrefix(attachmentPath, setting.AppURL) { // external image
426+
return "", fmt.Errorf("external image")
427+
}
428+
parts := strings.Split(attachmentPath, "/attachments/")
429+
if len(parts) <= 1 {
430+
return "", fmt.Errorf("invalid attachment path: %s", attachmentPath)
431+
}
432+
433+
attachmentUUID := parts[len(parts)-1]
434+
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
435+
if err != nil {
436+
return "", err
437+
}
438+
439+
// "Doer" is theoretically not the correct permission check (as Doer created the action on which to send), but as this is batch processed the receipants can't be accessed.
440+
// Therefore we check the Doer, with which we counter leaking information as a Doer brute force attack on attachments would be possible.
441+
perm, err := access_model.GetUserRepoPermission(ctx, ctx.Issue.Repo, ctx.Doer)
442+
if err != nil {
443+
return "", err
444+
}
445+
if !perm.CanRead(unit.TypeIssues) {
446+
return "", fmt.Errorf("no permission")
447+
}
448+
449+
fr, err := storage.Attachments.Open(attachment.RelativePath())
450+
if err != nil {
451+
return "", err
452+
}
453+
defer fr.Close()
454+
455+
maxSize := setting.MailService.Base64EmbedImagesMaxSizePerEmail // at maximum read the whole available combined email size, to prevent maliciously large file reads
456+
457+
lr := &io.LimitedReader{R: fr, N: maxSize + 1}
458+
content, err := io.ReadAll(lr)
459+
if err != nil {
460+
return "", err
461+
}
462+
if len(content) > int(maxSize) {
463+
return "", fmt.Errorf("file size exceeds the embedded image max limit \\(%d bytes\\)", maxSize)
464+
}
465+
466+
if *totalEmbeddedImagesSize+int64(len(content)) > setting.MailService.Base64EmbedImagesMaxSizePerEmail {
467+
return "", fmt.Errorf("total embedded images exceed max limit: %d > %d", *totalEmbeddedImagesSize+int64(len(content)), setting.MailService.Base64EmbedImagesMaxSizePerEmail)
468+
}
469+
*totalEmbeddedImagesSize += int64(len(content))
470+
471+
mimeType := http.DetectContentType(content)
472+
473+
if !strings.HasPrefix(mimeType, "image/") {
474+
return "", fmt.Errorf("not an image")
475+
}
476+
477+
encoded := base64.StdEncoding.EncodeToString(content)
478+
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
479+
480+
return dataURI, nil
481+
}
482+
362483
func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
363484
var path string
364485
if issue.IsPull {

0 commit comments

Comments
 (0)