mirror of
https://github.com/go-gitea/gitea.git
synced 2025-02-20 11:43:57 +08:00
feat: Add support for embedding images in emails as base64 data URIs
This commit is contained in:
parent
fc1b383da9
commit
b2db1a7fe0
@ -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
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
@ -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{}
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user