Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion models/asymkey/ssh_key_principals.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content
if !email.IsActivated {
continue
}
if content == email.Email {
if strings.EqualFold(content, email.LowerEmail) {
return content, nil
}
}
Expand Down
21 changes: 6 additions & 15 deletions modules/setting/incoming_email.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
"code.gitea.io/gitea/modules/log"
)

const IncomingEmailTokenPlaceholder = "%{token}"

var IncomingEmail = struct {
Enabled bool
ReplyToAddress string
TokenPlaceholder string `ini:"-"`
Host string
Port int
UseTLS bool `ini:"USE_TLS"`
Expand All @@ -28,7 +29,6 @@ var IncomingEmail = struct {
}{
Mailbox: "INBOX",
DeleteHandledMessage: true,
TokenPlaceholder: "%{token}",
MaximumMessageSize: 10485760,
}

Expand All @@ -54,19 +54,10 @@ func checkReplyToAddress() error {
return errors.New("name must not be set")
}

c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder)
switch c {
case 0:
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
case 1:
default:
return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder)
}

parts := strings.Split(IncomingEmail.ReplyToAddress, "@")
if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) {
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
placeholderCount := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmailTokenPlaceholder)
userPart, _, _ := strings.Cut(IncomingEmail.ReplyToAddress, "@")
if placeholderCount != 1 || !strings.Contains(userPart, IncomingEmailTokenPlaceholder) {
Comment thread
wxiaoguang marked this conversation as resolved.
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmailTokenPlaceholder)
}

return nil
}
72 changes: 29 additions & 43 deletions services/mailer/incoming/incoming.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"errors"
"fmt"
net_mail "net/mail"
"regexp"
"strings"
"time"

Expand All @@ -24,31 +23,10 @@ import (
"github.com/jhillyerd/enmime/v2"
)

var (
addressTokenRegex *regexp.Regexp
referenceTokenRegex *regexp.Regexp
)

func Init(ctx context.Context) error {
if !setting.IncomingEmail.Enabled {
return nil
}

var err error
addressTokenRegex, err = regexp.Compile(
fmt.Sprintf(
`\A%s\z`,
strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1),
),
)
if err != nil {
return err
}
referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain)))
if err != nil {
return err
}

go func() {
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true)
defer finished()
Expand Down Expand Up @@ -292,22 +270,31 @@ func isAutomaticReply(env *enmime.Envelope) bool {
return autoRespond != ""
}

func extractToken(s, tokenPrefix, tokenSuffix string) string {
if len(s) <= len(tokenPrefix)+len(tokenSuffix) {
return ""
}
prefix, suffix := s[0:len(tokenPrefix)], s[len(s)-len(tokenSuffix):]
if strings.EqualFold(prefix, tokenPrefix) && strings.EqualFold(suffix, tokenSuffix) {
return s[len(tokenPrefix) : len(s)-len(tokenSuffix)]
}
return ""
}

// searchTokenInHeaders looks for the token in To, Delivered-To and References
func searchTokenInHeaders(env *enmime.Envelope) string {
if addressTokenRegex != nil {
to, _ := env.AddressList("To")
to, _ := env.AddressList("To")

token := searchTokenInAddresses(to)
if token != "" {
return token
}
token := searchTokenInAddresses(to)
if token != "" {
return token
}

deliveredTo, _ := env.AddressList("Delivered-To")
deliveredTo, _ := env.AddressList("Delivered-To")

token = searchTokenInAddresses(deliveredTo)
if token != "" {
return token
}
token = searchTokenInAddresses(deliveredTo)
if token != "" {
return token
}

references := env.GetHeader("References")
Expand All @@ -322,10 +309,9 @@ func searchTokenInHeaders(env *enmime.Envelope) string {
if end == -1 || begin > end {
break
}

match := referenceTokenRegex.FindStringSubmatch(references[begin:end])
if len(match) == 2 {
return match[1]
t := extractToken(references[begin:end], "reply-", "@"+setting.Domain)
if t != "" {
return t
}

references = references[end+1:]
Expand All @@ -336,15 +322,15 @@ func searchTokenInHeaders(env *enmime.Envelope) string {

// searchTokenInAddresses looks for the token in an address
func searchTokenInAddresses(addresses []*net_mail.Address) string {
tokenPrefix, tokenSuffix, _ := strings.Cut(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder)
if tokenSuffix == "" {
return ""
}
for _, address := range addresses {
match := addressTokenRegex.FindStringSubmatch(address.Address)
if len(match) != 2 {
continue
if t := extractToken(address.Address, tokenPrefix, tokenSuffix); t != "" {
return t
}

return match[1]
}

return ""
}

Expand Down
14 changes: 14 additions & 0 deletions services/mailer/incoming/incoming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"testing"

"code.gitea.io/gitea/modules/setting"

"github.com/jhillyerd/enmime/v2"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -68,6 +70,18 @@ func TestIsAutomaticReply(t *testing.T) {
}
}

func TestSearchTokenInHeadersCaseInsensitive(t *testing.T) {
setting.IncomingEmail.ReplyToAddress = "InComing+%{token}@ExAmPle.com"
setting.Domain = "DoMain.com"
mkEnv := func(s string) *enmime.Envelope {
env, _ := enmime.ReadEnvelope(strings.NewReader(s + "\r\n\r\n"))
Comment thread
silverwind marked this conversation as resolved.
return env
}
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("To: incoming+abc@EXAMPLE.COM")))
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("Delivered-To: INCOMING+abc@example.com")))
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("References: <ReplY-abc@DomaiN.COM>")))
}

func TestGetContentFromMailReader(t *testing.T) {
mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" +
"\r\n" +
Expand Down
4 changes: 2 additions & 2 deletions services/mailer/mail_issue_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
msg.ReplyTo = replyAddress
msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))

Expand All @@ -194,7 +194,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
}
}
Expand Down
5 changes: 4 additions & 1 deletion services/mailer/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/sha256"
"encoding/base32"
"fmt"
"strings"
"time"

user_model "code.gitea.io/gitea/models/user"
Expand Down Expand Up @@ -75,7 +76,9 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er

// ExtractToken extracts the action/user tuple from the token and verifies the content
func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) {
data, err := encodingWithoutPadding.DecodeString(token)
// MTAs are permitted to alter the case of the local-part (RFC 5321 §2.4), so normalise
// to the base32 alphabet before decoding to survive a lowercased reply-to address.
data, err := encodingWithoutPadding.DecodeString(strings.ToUpper(token))
if err != nil {
return UnknownHandlerType, nil, nil, err
Comment thread
silverwind marked this conversation as resolved.
}
Comment thread
silverwind marked this conversation as resolved.
Outdated
Expand Down
9 changes: 8 additions & 1 deletion tests/integration/incoming_email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ func TestIncomingEmail(t *testing.T) {
assert.Equal(t, token_service.ReplyHandlerType, ht)
assert.Equal(t, user.ID, u.ID)
assert.Equal(t, payload, p)

// MTAs may lowercase the local-part of the reply-to address (RFC 5321 §2.4).
ht, u, p, err = token_service.ExtractToken(t.Context(), strings.ToLower(token))
assert.NoError(t, err)
assert.Equal(t, token_service.ReplyHandlerType, ht)
assert.Equal(t, user.ID, u.ID)
assert.Equal(t, payload, p)
})

t.Run("Handler", func(t *testing.T) {
Expand Down Expand Up @@ -189,7 +196,7 @@ func TestIncomingEmail(t *testing.T) {
assert.NoError(t, err)

msg := sender_service.NewMessageFrom(
strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1),
strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1),
"",
user.Email,
"",
Expand Down