Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,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 an image attachment to be embedded in the email.
;BASE64_EMBED_IMAGES_MAX_SIZE = 5242880

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
13 changes: 13 additions & 0 deletions models/fixtures/attachment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,16 @@
download_count: 0
size: 0
created_unix: 946684800

-
id: 13
uuid: 1b267670-1793-4cd0-abc1-449269b7cff9
repo_id: 1
issue_id: 2
release_id: 0
uploader_id: 2
comment_id: 0
name: gitea.png
download_count: 0
size: 1458
created_unix: 946684800
24 changes: 14 additions & 10 deletions modules/setting/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Base64EmbedImagesMaxSizePerAttachment int64 `ini:"BASE64_EMBED_IMAGES_MAX_SIZE_PER_ATTACHMENT"`

// SMTP sender
Protocol string `ini:"PROTOCOL"`
Expand Down Expand Up @@ -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_ATTACHMENT").MustInt64(5 * 1024 * 1024)

// Now map the values on to the MailService
MailService = &Mailer{}
Expand Down
118 changes: 115 additions & 3 deletions services/mailer/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ package mailer
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html/template"
"io"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
Expand All @@ -18,19 +21,23 @@ 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"
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/base"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
"code.gitea.io/gitea/services/mailer/token"

"golang.org/x/net/html"
"gopkg.in/gomail.v2"
)

Expand Down Expand Up @@ -195,7 +202,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
SendAsync(msg)
}

func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) {
func composeIssueCommentMessages(ctx *MailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) {
var (
subject string
link string
Expand Down Expand Up @@ -232,6 +239,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" {
Expand Down Expand Up @@ -363,6 +379,102 @@ 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 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)
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) (string, error) {
maxSizePerImageAttachment := setting.MailService.Base64EmbedImagesMaxSizePerAttachment
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()

content, err := io.ReadAll(fr)
if err != nil {
return "", err
}

if len(content) > int(maxSizePerImageAttachment) {
return "", fmt.Errorf("image too large (%d bytes) of max %d bytes", len(content), maxSizePerImageAttachment)
}

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 {
Expand Down Expand Up @@ -394,7 +506,7 @@ func generateMessageIDForRelease(release *repo_model.Release) string {
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
}

func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
func generateAdditionalHeaders(ctx *MailCommentContext, reason string, recipient *user_model.User) map[string]string {
repo := ctx.Issue.Repo

return map[string]string{
Expand Down Expand Up @@ -458,7 +570,7 @@ func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer
}

for lang, tos := range langMap {
msgs, err := composeIssueCommentMessages(&mailCommentContext{
msgs, err := composeIssueCommentMessages(&MailCommentContext{
Context: ctx,
Issue: issue,
Doer: doer,
Expand Down
4 changes: 2 additions & 2 deletions services/mailer/mail_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func MailParticipantsComment(ctx context.Context, c *issues_model.Comment, opTyp
content = ""
}
if err := mailIssueCommentToParticipants(
&mailCommentContext{
&MailCommentContext{
Context: ctx,
Issue: issue,
Doer: c.Poster,
Expand All @@ -49,7 +49,7 @@ func MailMentionsComment(ctx context.Context, pr *issues_model.PullRequest, c *i
visited := make(container.Set[int64], len(mentions)+1)
visited.Add(c.Poster.ID)
if err = mailIssueCommentBatch(
&mailCommentContext{
&MailCommentContext{
Context: ctx,
Issue: pr.Issue,
Doer: c.Poster,
Expand Down
8 changes: 4 additions & 4 deletions services/mailer/mail_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func fallbackMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}

type mailCommentContext struct {
type MailCommentContext struct {
context.Context
Issue *issues_model.Issue
Doer *user_model.User
Expand All @@ -41,7 +41,7 @@ const (
// This function sends two list of emails:
// 1. Repository watchers (except for WIP pull requests) and users who are participated in comments.
// 2. Users who are not in 1. but get mentioned in current issue/comment.
func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_model.User) error {
func mailIssueCommentToParticipants(ctx *MailCommentContext, mentions []*user_model.User) error {
// Required by the mail composer; make sure to load these before calling the async function
if err := ctx.Issue.LoadRepo(ctx); err != nil {
return fmt.Errorf("LoadRepo: %w", err)
Expand Down Expand Up @@ -120,7 +120,7 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo
return nil
}

func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, visited container.Set[int64], fromMention bool) error {
func mailIssueCommentBatch(ctx *MailCommentContext, users []*user_model.User, visited container.Set[int64], fromMention bool) error {
checkUnit := unit.TypeIssues
if ctx.Issue.IsPull {
checkUnit = unit.TypePullRequests
Expand Down Expand Up @@ -186,7 +186,7 @@ func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user
}
forceDoerNotification := opType == activities_model.ActionAutoMergePullRequest
if err := mailIssueCommentToParticipants(
&mailCommentContext{
&MailCommentContext{
Context: ctx,
Issue: issue,
Doer: doer,
Expand Down
18 changes: 9 additions & 9 deletions services/mailer/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestComposeIssueCommentMessage(t *testing.T) {
bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))

recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}, {Name: "Test2", Email: "[email protected]"}}
msgs, err := composeIssueCommentMessages(&mailCommentContext{
msgs, err := composeIssueCommentMessages(&MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
Expand Down Expand Up @@ -129,7 +129,7 @@ func TestComposeIssueMessage(t *testing.T) {
bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))

recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}, {Name: "Test2", Email: "[email protected]"}}
msgs, err := composeIssueCommentMessages(&mailCommentContext{
msgs, err := composeIssueCommentMessages(&MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
Content: "test body",
Expand Down Expand Up @@ -176,14 +176,14 @@ func TestTemplateSelection(t *testing.T) {
assert.Contains(t, wholemsg, expBody)
}

msg := testComposeIssueCommentMessage(t, &mailCommentContext{
msg := testComposeIssueCommentMessage(t, &MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
Content: "test body",
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "issue/new/subject", "issue/new/body")

msg = testComposeIssueCommentMessage(t, &mailCommentContext{
msg = testComposeIssueCommentMessage(t, &MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
Content: "test body", Comment: comment,
Expand All @@ -192,14 +192,14 @@ func TestTemplateSelection(t *testing.T) {

pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, Repo: repo, Poster: doer})
comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Issue: pull})
msg = testComposeIssueCommentMessage(t, &mailCommentContext{
msg = testComposeIssueCommentMessage(t, &MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
Content: "test body", Comment: comment,
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "pull/comment/subject", "pull/comment/body")

msg = testComposeIssueCommentMessage(t, &mailCommentContext{
msg = testComposeIssueCommentMessage(t, &MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
Content: "test body", Comment: comment,
Expand All @@ -218,7 +218,7 @@ func TestTemplateServices(t *testing.T) {
bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))

recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}}
msg := testComposeIssueCommentMessage(t, &mailCommentContext{
msg := testComposeIssueCommentMessage(t, &MailCommentContext{
Context: context.TODO(), // TODO: use a correct context
Issue: issue, Doer: doer, ActionType: actionType,
Content: "test body", Comment: comment,
Expand Down Expand Up @@ -252,7 +252,7 @@ func TestTemplateServices(t *testing.T) {
"//Re: //")
}

func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message {
func testComposeIssueCommentMessage(t *testing.T, ctx *MailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message {
msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info)
assert.NoError(t, err)
assert.Len(t, msgs, 1)
Expand All @@ -262,7 +262,7 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recip
func TestGenerateAdditionalHeaders(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)

ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
ctx := &MailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
recipient := &user_model.User{Name: "test", Email: "[email protected]"}

headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient)
Expand Down
Loading
Loading