feat: Add support for embedding images in emails as base64 data URIs

This commit is contained in:
Felix Sommer 2025-02-16 07:15:18 +01:00
parent fc1b383da9
commit b2db1a7fe0
No known key found for this signature in database
GPG Key ID: 5B177CCCD31B5422
3 changed files with 141 additions and 10 deletions

View File

@ -1767,6 +1767,12 @@ 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.
;BASE64_EMBED_IMAGES = false
;;
;; The maximum size of sum of all images in a single email. Default is 9.5MB
;BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL = 9961472
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -13,21 +13,23 @@ import (
"code.gitea.io/gitea/modules/log"
shellquote "github.com/kballard/go-shellquote"
"github.com/kballard/go-shellquote"
)
// Mailer represents mail service.
type Mailer struct {
// Mailer
Name string `ini:"NAME"`
From string `ini:"FROM"`
EnvelopeFrom string `ini:"ENVELOPE_FROM"`
OverrideEnvelopeFrom bool `ini:"-"`
FromName string `ini:"-"`
FromEmail string `ini:"-"`
SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"`
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
OverrideHeader map[string][]string `ini:"-"`
Name string `ini:"NAME"`
From string `ini:"FROM"`
EnvelopeFrom string `ini:"ENVELOPE_FROM"`
OverrideEnvelopeFrom bool `ini:"-"`
FromName string `ini:"-"`
FromEmail string `ini:"-"`
SendAsPlainText bool `ini:"SEND_AS_PLAIN_TEXT"`
SubjectPrefix string `ini:"SUBJECT_PREFIX"`
OverrideHeader map[string][]string `ini:"-"`
Base64EmbedImages bool `ini:"BASE64_EMBED_IMAGES"`
Base64EmbedImagesMaxSizePerEmail int64 `ini:"BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL"`
// SMTP sender
Protocol string `ini:"PROTOCOL"`
@ -150,6 +152,8 @@ func loadMailerFrom(rootCfg ConfigProvider) {
sec.Key("SENDMAIL_TIMEOUT").MustDuration(5 * time.Minute)
sec.Key("SENDMAIL_CONVERT_CRLF").MustBool(true)
sec.Key("FROM").MustString(sec.Key("USER").String())
sec.Key("BASE64_EMBED_IMAGES").MustBool(false)
sec.Key("BASE64_EMBED_IMAGES_MAX_SIZE_PER_EMAIL").MustInt64(9.5 * 1024 * 1024)
// Now map the values on to the MailService
MailService = &Mailer{}

View File

@ -7,9 +7,12 @@ package mailer
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html/template"
"io"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
@ -18,19 +21,24 @@ import (
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
sender_service "code.gitea.io/gitea/services/mailer/sender"
"code.gitea.io/gitea/services/mailer/token"
"golang.org/x/net/html"
)
const (
@ -228,6 +236,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
return nil, err
}
if setting.MailService.Base64EmbedImages {
bodyStr := string(body)
bodyStr, err = Base64InlineImages(bodyStr, ctx)
if err != nil {
return nil, err
}
body = template.HTML(bodyStr)
}
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
if actName != "new" {
@ -359,6 +376,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
return msgs, nil
}
func Base64InlineImages(body string, ctx *mailCommentContext) (string, error) {
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
log.Error("Failed to parse HTML body: %v", err)
return "", err
}
var totalEmbeddedImagesSize int64
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" {
attachmentPath := attr.Val
dataURI, err := AttachmentSrcToBase64DataURI(attachmentPath, ctx, &totalEmbeddedImagesSize)
if err != nil {
log.Trace("attachmentSrcToDataURI not possible: %v", err) // Not an error, just skip. This is probably an image from outside the gitea instance.
continue
}
log.Trace("Old value of src attribute: %s, new value (first 100 characters): %s", attr.Val, dataURI[:100])
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 {
log.Error("Failed to render modified HTML: %v", err)
return "", err
}
return buf.String(), nil
}
func AttachmentSrcToBase64DataURI(attachmentPath string, ctx *mailCommentContext, totalEmbeddedImagesSize *int64) (string, error) {
if !strings.HasPrefix(attachmentPath, setting.AppURL) { // external image
return "", fmt.Errorf("external image")
}
parts := strings.Split(attachmentPath, "/attachments/")
if len(parts) <= 1 {
return "", fmt.Errorf("invalid attachment path: %s", attachmentPath)
}
attachmentUUID := parts[len(parts)-1]
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
if err != nil {
return "", err
}
// "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.
// Therefore we check the Doer, with which we counter leaking information as a Doer brute force attack on attachments would be possible.
perm, err := access_model.GetUserRepoPermission(ctx, ctx.Issue.Repo, ctx.Doer)
if err != nil {
return "", err
}
if !perm.CanRead(unit.TypeIssues) {
return "", fmt.Errorf("no permission")
}
fr, err := storage.Attachments.Open(attachment.RelativePath())
if err != nil {
return "", err
}
defer fr.Close()
maxSize := setting.MailService.Base64EmbedImagesMaxSizePerEmail // at maximum read the whole available combined email size, to prevent maliciously large file reads
lr := &io.LimitedReader{R: fr, N: maxSize + 1}
content, err := io.ReadAll(lr)
if err != nil {
return "", err
}
if len(content) > int(maxSize) {
return "", fmt.Errorf("file size exceeds the embedded image max limit \\(%d bytes\\)", maxSize)
}
if *totalEmbeddedImagesSize+int64(len(content)) > setting.MailService.Base64EmbedImagesMaxSizePerEmail {
return "", fmt.Errorf("total embedded images exceed max limit: %d > %d", *totalEmbeddedImagesSize+int64(len(content)), setting.MailService.Base64EmbedImagesMaxSizePerEmail)
}
*totalEmbeddedImagesSize += int64(len(content))
mimeType := http.DetectContentType(content)
if !strings.HasPrefix(mimeType, "image/") {
return "", fmt.Errorf("not an image")
}
encoded := base64.StdEncoding.EncodeToString(content)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
return dataURI, nil
}
func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
var path string
if issue.IsPull {