Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add creating new issue by email support #33571

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
32 changes: 32 additions & 0 deletions models/user/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,35 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string
return err
})
}

type RepositoryRandsType string

const (
RepositoryRandsTypeNewIssue RepositoryRandsType = "new_issue"
)

func CreatRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) {
rand, err := GetUserSalt()
if err != nil {
return rand, err
}

return rand, SetUserSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event)), rand)
}

func GetRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) {
return GetSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event)))
}

func (u *User) GetOrCreateRandsForRepository(ctx context.Context, repoID int64, event RepositoryRandsType) (string, error) {
rand, err := GetRandsForRepository(ctx, u.ID, repoID, event)
if err != nil && !IsErrUserSettingIsNotExist(err) {
return "", err
}

if len(rand) == 0 || err != nil {
rand, err = CreatRandsForRepository(ctx, u.ID, repoID, event)
}

return rand, err
}
10 changes: 10 additions & 0 deletions models/user/setting_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

package user

import "fmt"

const (
// SettingsKeyHiddenCommentTypes is the setting key for hidden comment types
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
Expand All @@ -19,3 +21,11 @@ const (
// SignupUserAgent is the user agent that the user signed up with
SignupUserAgent = "signup.user_agent"
)

func SettingsKeyUserRands(key string) string {
return "rands." + key
}

func SettingsKeyUserRandsForRepo(repoID int64, key string) string {
return SettingsKeyUserRands(fmt.Sprintf("repo.%d.%s", repoID, key))
}
7 changes: 7 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,13 @@ issues.content_history.delete_from_history_confirm = Delete from history?
issues.content_history.options = Options
issues.reference_link = Reference: %s

issues.mailto_modal.title = Create new issue by email
issues.mailto_modal.desc_1 = You can create a new issue inside this project by sending an email to the following email address:
issues.mailto_modal.desc_2 = The subject will be used as the title of the new issue, and the message will be the description.
issues.mailto_modal.desc_3 = `This is a private email address generated just for you. Anyone who has it can create issues as if they were you. If that happens, <a href="#" class="%s">reset this token</a>.`
issues.mailto_modal.mailto_link = Email a new issue to this repository
issues.mailto_modal.send_mail = send mail

compare.compare_base = base
compare.compare_head = compare

Expand Down
32 changes: 32 additions & 0 deletions routers/web/repo/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
issue_service "code.gitea.io/gitea/services/issue"
"code.gitea.io/gitea/services/mailer/incoming"
pull_service "code.gitea.io/gitea/services/pull"
)

Expand Down Expand Up @@ -780,5 +781,36 @@ func Issues(ctx *context.Context) {

ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList)

if !isPullList {
err := renderMailToIssue(ctx)
if err != nil {
ctx.ServerError("renderMailToIssue", err)
return
}
}

ctx.HTML(http.StatusOK, tplIssues)
}

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

if !ctx.IsSigned {
return nil
}

token, mailToAddress, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, ctx.Repo.Repository, user_model.RepositoryRandsTypeNewIssue)
if err != nil {
return err
}

ctx.Data["MailToIssueEnabled"] = true
ctx.Data["MailToIssueAddress"] = mailToAddress
ctx.Data["MailToIssueLink"] = fmt.Sprintf("mailto:%s", mailToAddress)
ctx.Data["MailToIssueToken"] = token
ctx.Data["MailToIssueTokenResetUrl"] = fmt.Sprintf("%s/user/settings/repo_mailto_rands_reset/%d", setting.AppSubURL, ctx.Repo.Repository.ID)

return nil
}
45 changes: 45 additions & 0 deletions routers/web/repo/issue_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"testing"

repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/contexttest"
"code.gitea.io/gitea/services/mailer/token"

"github.com/stretchr/testify/assert"
)

func TestRenderMailToIssue(t *testing.T) {
unittest.PrepareTestEnv(t)

ctx, _ := contexttest.MockContext(t, "user2/repo1")

ctx.IsSigned = true
ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
ctx.Repo = &context.Repository{
Repository: unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}),
}

setting.IncomingEmail.Enabled = true
setting.IncomingEmail.ReplyToAddress = "test%{token}@gitea.io"
setting.IncomingEmail.TokenPlaceholder = "%{token}"

err := renderMailToIssue(ctx)
assert.NoError(t, err)

key, ok := ctx.Data["MailToIssueToken"].(string)
assert.True(t, ok)

handlerType, user, _, err := token.ExtractToken(ctx, key)
assert.NoError(t, err)
assert.EqualValues(t, token.NewIssueHandlerType, handlerType)
assert.EqualValues(t, ctx.Doer.ID, user.ID)
}
37 changes: 37 additions & 0 deletions routers/web/user/setting/repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package setting

import (
"net/http"
"strconv"

repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer/incoming"
)

func ResetRepoMailToRands(ctx *context.Context) {
repoID, _ := strconv.ParseInt(ctx.PathParam("repo_id"), 10, 64)
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}

_, err = user_model.CreatRandsForRepository(ctx, ctx.Doer.ID, repo.ID, user_model.RepositoryRandsTypeNewIssue)
if err != nil {
ctx.ServerError("CreatRandsForRepository", err)
return
}

_, url, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, repo, user_model.RepositoryRandsTypeNewIssue)
if err != nil {
ctx.ServerError("GenerateMailToRepoURL", err)
return
}

ctx.JSON(http.StatusOK, map[string]string{"url": url})
}
2 changes: 2 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ func registerRoutes(m *web.Router) {
m.Get("", user_setting.BlockedUsers)
m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost)
})

m.Post("/repo_mailto_rands_reset/{repo_id}", user_setting.ResetRepoMailToRands)
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled))

m.Group("/user", func() {
Expand Down
2 changes: 2 additions & 0 deletions services/mailer/incoming/incoming.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ loop:
}

content := getContentFromMailReader(env)
content.Subject = env.GetHeader("Subject")

if err := handler.Handle(ctx, content, user, payload); err != nil {
return fmt.Errorf("could not handle message: %w", err)
Expand Down Expand Up @@ -350,6 +351,7 @@ func searchTokenInAddresses(addresses []*net_mail.Address) string {
type MailContent struct {
Content string
Attachments []*Attachment
Subject string
}

type Attachment struct {
Expand Down
84 changes: 82 additions & 2 deletions services/mailer/incoming/incoming_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ package incoming
import (
"bytes"
"context"
"errors"
"fmt"

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/log"
"code.gitea.io/gitea/modules/setting"
Expand All @@ -28,8 +30,10 @@ type MailHandler interface {
}

var handlers = map[token.HandlerType]MailHandler{
token.ReplyHandlerType: &ReplyHandler{},
token.UnsubscribeHandlerType: &UnsubscribeHandler{},
token.ReplyHandlerType: &ReplyHandler{},
token.UnsubscribeHandlerType: &UnsubscribeHandler{},
token.NewIssueHandlerType: &NewIssueHandler{},
token.NewPullRequestHandlerType: &NewPullRequest{},
}

// ReplyHandler handles incoming emails to create a reply from them
Expand Down Expand Up @@ -178,3 +182,79 @@ func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *u

return fmt.Errorf("unsupported unsubscribe reference: %v", ref)
}

// NewIssueHandler handles new issues
type NewIssueHandler struct{}

func (h *NewIssueHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error {
if doer == nil {
return util.NewInvalidArgumentErrorf("doer can't be nil")
}

ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload)
if err != nil {
return err
}

var repo *repo_model.Repository

switch r := ref.(type) {
case *repo_model.Repository:
repo = r
default:
return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref)
}

if util.IsEmptyString(content.Subject) {
return nil
}

perm, err := access_model.GetUserRepoPermission(ctx, repo, doer)
if err != nil {
return err
}
if !perm.CanRead(unit.TypeIssues) {
return nil
}

attachmentIDs := make([]string, 0, len(content.Attachments))
if setting.Attachment.Enabled {
for _, attachment := range content.Attachments {
a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{
Name: attachment.Name,
UploaderID: doer.ID,
RepoID: repo.ID,
})
if err != nil {
if upload.IsErrFileTypeForbidden(err) {
log.Info("NewIssueHandler: Skipping disallowed attachment type: %s", attachment.Name)
continue
}
return err
}
attachmentIDs = append(attachmentIDs, a.UUID)
}
}

issue := &issues_model.Issue{
RepoID: repo.ID,
Repo: repo,
Title: content.Subject,
PosterID: doer.ID,
Poster: doer,
Content: content.Content,
}

if err := issue_service.NewIssue(ctx, repo, issue, []int64{}, attachmentIDs, []int64{}, 0); err != nil {
log.Warn("NewIssueHandler: Failed to create issue: %v", err)
}

return nil
}

// NewPullRequest handles new pull requests
type NewPullRequest struct{}

func (h *NewPullRequest) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error {
return errors.New("not implemented")
}
38 changes: 38 additions & 0 deletions services/mailer/incoming/mailto_new_issue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package incoming

import (
"context"
"strings"

repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
"code.gitea.io/gitea/services/mailer/token"
)

func GenerateMailToRepoURL(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, event user_model.RepositoryRandsType) (string, string, error) {
_, err := doer.GetOrCreateRandsForRepository(ctx, repo.ID, event)
if err != nil {
return "", "", err
}

payload, err := incoming_payload.CreateReferencePayload(&incoming_payload.ReferenceRepository{
RepositoryID: repo.ID,
ActionType: incoming_payload.ReferenceRepositoryActionTypeNewIssue,
})
if err != nil {
return "", "", err
}

token, err := token.CreateToken(ctx, token.NewIssueHandlerType, doer, payload)
if err != nil {
return "", "", err
}

mailToAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
return token, mailToAddress, nil
}
Loading
Loading