From 0f533241829d0d48aa16a91e7dc0614fe50bc317 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Sun, 14 Jul 2024 14:27:00 -0700
Subject: [PATCH] Add option to change mail from user display name (#31528)

Make it posible to let mails show e.g.:

`Max Musternam (via gitea.kithara.com) <gitea@kithara.com>`

Docs: https://gitea.com/gitea/docs/pulls/23

---
*Sponsored by Kithara Software GmbH*
---
 custom/conf/app.example.ini     |  4 +++
 modules/setting/mailer.go       | 15 +++++++++++
 services/mailer/mail.go         | 18 ++++++++++++-
 services/mailer/mail_release.go |  2 +-
 services/mailer/mail_repo.go    |  2 +-
 services/mailer/mail_test.go    | 48 +++++++++++++++++++++++++++++++++
 6 files changed, 86 insertions(+), 3 deletions(-)

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index f522b9da28..c29d2e5be4 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1676,6 +1676,10 @@ LEVEL = Info
 ;; Sometimes it is helpful to use a different address on the envelope. Set this to use ENVELOPE_FROM as the from on the envelope. Set to `<>` to send an empty address.
 ;ENVELOPE_FROM =
 ;;
+;; If gitea sends mails on behave of users, it will just use the name also displayed in the WebUI. If you want e.g. `Mister X (by CodeIt) <gitea@codeit.net>`,
+;; set it to `{{ .DisplayName }} (by {{ .AppName }})`. Available Variables: `.DisplayName`, `.AppName` and `.Domain`.
+;FROM_DISPLAY_NAME_FORMAT = {{ .DisplayName }}
+;;
 ;; Mailer user name and password, if required by provider.
 ;USER =
 ;;
diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go
index 58bfd67bfb..d4db55dc7b 100644
--- a/modules/setting/mailer.go
+++ b/modules/setting/mailer.go
@@ -8,6 +8,7 @@ import (
 	"net"
 	"net/mail"
 	"strings"
+	"text/template"
 	"time"
 
 	"code.gitea.io/gitea/modules/log"
@@ -46,6 +47,10 @@ type Mailer struct {
 	SendmailArgs        []string      `ini:"-"`
 	SendmailTimeout     time.Duration `ini:"SENDMAIL_TIMEOUT"`
 	SendmailConvertCRLF bool          `ini:"SENDMAIL_CONVERT_CRLF"`
+
+	// Customization
+	FromDisplayNameFormat         string             `ini:"FROM_DISPLAY_NAME_FORMAT"`
+	FromDisplayNameFormatTemplate *template.Template `ini:"-"`
 }
 
 // MailService the global mailer
@@ -226,6 +231,16 @@ func loadMailerFrom(rootCfg ConfigProvider) {
 		log.Error("no mailer.FROM provided, email system may not work.")
 	}
 
+	MailService.FromDisplayNameFormatTemplate, _ = template.New("mailFrom").Parse("{{ .DisplayName }}")
+	if MailService.FromDisplayNameFormat != "" {
+		template, err := template.New("mailFrom").Parse(MailService.FromDisplayNameFormat)
+		if err != nil {
+			log.Error("mailer.FROM_DISPLAY_NAME_FORMAT is no valid template: %v", err)
+		} else {
+			MailService.FromDisplayNameFormatTemplate = template
+		}
+	}
+
 	switch MailService.EnvelopeFrom {
 	case "":
 		MailService.OverrideEnvelopeFrom = false
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index 1f5df6efe7..23c91595b7 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -314,7 +314,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
 	for _, recipient := range recipients {
 		msg := NewMessageFrom(
 			recipient.Email,
-			ctx.Doer.GetCompleteName(),
+			fromDisplayName(ctx.Doer),
 			setting.MailService.FromEmail,
 			subject,
 			mailBody.String(),
@@ -536,3 +536,19 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
 	}
 	return typeName, name, template
 }
+
+func fromDisplayName(u *user_model.User) string {
+	if setting.MailService.FromDisplayNameFormatTemplate != nil {
+		var ctx bytes.Buffer
+		err := setting.MailService.FromDisplayNameFormatTemplate.Execute(&ctx, map[string]any{
+			"DisplayName": u.DisplayName(),
+			"AppName":     setting.AppName,
+			"Domain":      setting.Domain,
+		})
+		if err == nil {
+			return mime.QEncoding.Encode("utf-8", ctx.String())
+		}
+		log.Error("fromDisplayName: %w", err)
+	}
+	return u.GetCompleteName()
+}
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index f1f2e558a7..01a8929e2d 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -86,7 +86,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
 	}
 
 	msgs := make([]*Message, 0, len(tos))
-	publisherName := rel.Publisher.DisplayName()
+	publisherName := fromDisplayName(rel.Publisher)
 	msgID := generateMessageIDForRelease(rel)
 	for _, to := range tos {
 		msg := NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String())
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index 28b9cef8a7..7003584786 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -79,7 +79,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
 	}
 
 	for _, to := range emailTos {
-		msg := NewMessage(to.EmailTo(), subject, content.String())
+		msg := NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String())
 		msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID)
 
 		SendAsync(msg)
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index 0739f4233f..40fd21dea5 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -403,3 +403,51 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
 	})
 	assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
 }
+
+func TestFromDisplayName(t *testing.T) {
+	template, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
+	assert.NoError(t, err)
+	setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+	defer func() { setting.MailService = nil }()
+
+	tests := []struct {
+		userDisplayName string
+		fromDisplayName string
+	}{{
+		userDisplayName: "test",
+		fromDisplayName: "test",
+	}, {
+		userDisplayName: "Hi Its <Mee>",
+		fromDisplayName: "Hi Its <Mee>",
+	}, {
+		userDisplayName: "Æsir",
+		fromDisplayName: "=?utf-8?q?=C3=86sir?=",
+	}, {
+		userDisplayName: "new😀user",
+		fromDisplayName: "=?utf-8?q?new=F0=9F=98=80user?=",
+	}}
+
+	for _, tc := range tests {
+		t.Run(tc.userDisplayName, func(t *testing.T) {
+			user := &user_model.User{FullName: tc.userDisplayName, Name: "tmp"}
+			got := fromDisplayName(user)
+			assert.EqualValues(t, tc.fromDisplayName, got)
+		})
+	}
+
+	t.Run("template with all available vars", func(t *testing.T) {
+		template, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
+		assert.NoError(t, err)
+		setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+		oldAppName := setting.AppName
+		setting.AppName = "Code IT"
+		oldDomain := setting.Domain
+		setting.Domain = "code.it"
+		defer func() {
+			setting.AppName = oldAppName
+			setting.Domain = oldDomain
+		}()
+
+		assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
+	})
+}