Skip to content

Commit 1d9f2dd

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

File tree

3 files changed

+140
-10
lines changed

3 files changed

+140
-10
lines changed

custom/conf/app.example.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,12 @@ LEVEL = Info
17041704
;;
17051705
;; convert \r\n to \n for Sendmail
17061706
;SENDMAIL_CONVERT_CRLF = true
1707+
;;
1708+
;; convert links of attached images to inline images. Only for images hosted in this gitea instance.
1709+
;BASE64_EMBED_IMAGES = false
1710+
;;
1711+
;; The maximum size of sum of all images in a single email. Default is 9.5MB
1712+
;BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL = 9961472
17071713

17081714
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
17091715
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

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: 120 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,23 @@ 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
repo_model "code.gitea.io/gitea/models/repo"
26+
"code.gitea.io/gitea/models/unit"
2227
user_model "code.gitea.io/gitea/models/user"
2328
"code.gitea.io/gitea/modules/base"
2429
"code.gitea.io/gitea/modules/emoji"
2530
"code.gitea.io/gitea/modules/log"
2631
"code.gitea.io/gitea/modules/markup"
2732
"code.gitea.io/gitea/modules/markup/markdown"
2833
"code.gitea.io/gitea/modules/setting"
34+
"code.gitea.io/gitea/modules/storage"
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
"code.gitea.io/gitea/services/mailer/token"
3339

40+
"golang.org/x/net/html"
3441
"gopkg.in/gomail.v2"
3542
)
3643

@@ -232,6 +239,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
232239
return nil, err
233240
}
234241

242+
if setting.MailService.Base64EmbedImages {
243+
bodyStr := string(body)
244+
bodyStr, err = Base64InlineImages(bodyStr, ctx)
245+
if err != nil {
246+
return nil, err
247+
}
248+
body = template.HTML(bodyStr)
249+
}
250+
235251
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
236252

237253
if actName != "new" {
@@ -363,6 +379,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
363379
return msgs, nil
364380
}
365381

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

0 commit comments

Comments
 (0)