Skip to content

Commit da19f04

Browse files
committed
add creating new issue by email support
Signed-off-by: a1012112796 <[email protected]>
1 parent f58f5bb commit da19f04

File tree

17 files changed

+414
-12
lines changed

17 files changed

+414
-12
lines changed

models/user/setting.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,35 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string
210210
return err
211211
})
212212
}
213+
214+
type RepositoryRandsType string
215+
216+
const (
217+
RepositoryRandsTypeNewIssue RepositoryRandsType = "new_issue"
218+
)
219+
220+
func CreatRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) {
221+
rand, err := GetUserSalt()
222+
if err != nil {
223+
return rand, err
224+
}
225+
226+
return rand, SetUserSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event)), rand)
227+
}
228+
229+
func GetRandsForRepository(ctx context.Context, userID, repoID int64, event RepositoryRandsType) (string, error) {
230+
return GetSetting(ctx, userID, SettingsKeyUserRandsForRepo(repoID, string(event)))
231+
}
232+
233+
func (u *User) GetOrCreateRandsForRepository(ctx context.Context, repoID int64, event RepositoryRandsType) (string, error) {
234+
rand, err := GetRandsForRepository(ctx, u.ID, repoID, event)
235+
if err != nil && !IsErrUserSettingIsNotExist(err) {
236+
return "", err
237+
}
238+
239+
if len(rand) == 0 || err != nil {
240+
rand, err = CreatRandsForRepository(ctx, u.ID, repoID, event)
241+
}
242+
243+
return rand, err
244+
}

models/user/setting_keys.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
package user
55

6+
import "fmt"
7+
68
const (
79
// SettingsKeyHiddenCommentTypes is the setting key for hidden comment types
810
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
@@ -19,3 +21,11 @@ const (
1921
// SignupUserAgent is the user agent that the user signed up with
2022
SignupUserAgent = "signup.user_agent"
2123
)
24+
25+
func SettingsKeyUserRands(key string) string {
26+
return "rands." + key
27+
}
28+
29+
func SettingsKeyUserRandsForRepo(repoID int64, key string) string {
30+
return SettingsKeyUserRands(fmt.Sprintf("repo.%d.%s", repoID, key))
31+
}

options/locale/locale_en-US.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,13 @@ issues.content_history.delete_from_history_confirm = Delete from history?
18081808
issues.content_history.options = Options
18091809
issues.reference_link = Reference: %s
18101810

1811+
issues.mailto_modal.title = Create new issue by email
1812+
issues.mailto_modal.desc_1 = You can create a new issue inside this project by sending an email to the following email address:
1813+
issues.mailto_modal.desc_2 = The subject will be used as the title of the new issue, and the message will be the description.
1814+
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>.`
1815+
issues.mailto_modal.mailto_link = Email a new issue to this repository
1816+
issues.mailto_modal.send_mail = send mail
1817+
18111818
compare.compare_base = base
18121819
compare.compare_head = compare
18131820

routers/web/repo/issue_list.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"code.gitea.io/gitea/services/context"
2828
"code.gitea.io/gitea/services/convert"
2929
issue_service "code.gitea.io/gitea/services/issue"
30+
"code.gitea.io/gitea/services/mailer/incoming"
3031
pull_service "code.gitea.io/gitea/services/pull"
3132
)
3233

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

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

784+
if !isPullList {
785+
err := renderMailToIssue(ctx)
786+
if err != nil {
787+
ctx.ServerError("renderMailToIssue", err)
788+
return
789+
}
790+
}
791+
783792
ctx.HTML(http.StatusOK, tplIssues)
784793
}
794+
795+
func renderMailToIssue(ctx *context.Context) error {
796+
if !setting.IncomingEmail.Enabled {
797+
return nil
798+
}
799+
800+
if !ctx.IsSigned {
801+
return nil
802+
}
803+
804+
token, mailToAddress, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, ctx.Repo.Repository, user_model.RepositoryRandsTypeNewIssue)
805+
if err != nil {
806+
return err
807+
}
808+
809+
ctx.Data["MailToIssueEnabled"] = true
810+
ctx.Data["MailToIssueAddress"] = mailToAddress
811+
ctx.Data["MailToIssueLink"] = fmt.Sprintf("mailto:%s", mailToAddress)
812+
ctx.Data["MailToIssueToken"] = token
813+
ctx.Data["MailToIssueTokenResetUrl"] = fmt.Sprintf("%s/user/settings/repo_mailto_rands_reset/%d", setting.AppSubURL, ctx.Repo.Repository.ID)
814+
815+
return nil
816+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"testing"
8+
9+
repo_model "code.gitea.io/gitea/models/repo"
10+
"code.gitea.io/gitea/models/unittest"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/setting"
13+
"code.gitea.io/gitea/services/context"
14+
"code.gitea.io/gitea/services/contexttest"
15+
"code.gitea.io/gitea/services/mailer/token"
16+
17+
"github.com/stretchr/testify/assert"
18+
)
19+
20+
func TestRenderMailToIssue(t *testing.T) {
21+
unittest.PrepareTestEnv(t)
22+
23+
ctx, _ := contexttest.MockContext(t, "user2/repo1")
24+
25+
ctx.IsSigned = true
26+
ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
27+
ctx.Repo = &context.Repository{
28+
Repository: unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}),
29+
}
30+
31+
setting.IncomingEmail.Enabled = true
32+
setting.IncomingEmail.ReplyToAddress = "test%{token}@gitea.io"
33+
setting.IncomingEmail.TokenPlaceholder = "%{token}"
34+
35+
err := renderMailToIssue(ctx)
36+
assert.NoError(t, err)
37+
38+
key, ok := ctx.Data["MailToIssueToken"].(string)
39+
assert.True(t, ok)
40+
41+
handlerType, user, _, err := token.ExtractToken(ctx, key)
42+
assert.NoError(t, err)
43+
assert.EqualValues(t, token.NewIssueHandlerType, handlerType)
44+
assert.EqualValues(t, ctx.Doer.ID, user.ID)
45+
}

routers/web/user/setting/repo.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package setting
5+
6+
import (
7+
"net/http"
8+
"strconv"
9+
10+
repo_model "code.gitea.io/gitea/models/repo"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/services/context"
13+
"code.gitea.io/gitea/services/mailer/incoming"
14+
)
15+
16+
func ResetRepoMailToRands(ctx *context.Context) {
17+
repoID, _ := strconv.ParseInt(ctx.PathParam("repo_id"), 10, 64)
18+
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
19+
if err != nil {
20+
ctx.ServerError("GetRepositoryByID", err)
21+
return
22+
}
23+
24+
_, err = user_model.CreatRandsForRepository(ctx, ctx.Doer.ID, repo.ID, user_model.RepositoryRandsTypeNewIssue)
25+
if err != nil {
26+
ctx.ServerError("CreatRandsForRepository", err)
27+
return
28+
}
29+
30+
_, url, err := incoming.GenerateMailToRepoURL(ctx, ctx.Doer, repo, user_model.RepositoryRandsTypeNewIssue)
31+
if err != nil {
32+
ctx.ServerError("GenerateMailToRepoURL", err)
33+
return
34+
}
35+
36+
ctx.JSON(http.StatusOK, map[string]string{"url": url})
37+
}

routers/web/web.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,8 @@ func registerRoutes(m *web.Router) {
683683
m.Get("", user_setting.BlockedUsers)
684684
m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost)
685685
})
686+
687+
m.Post("/repo_mailto_rands_reset/{repo_id}", user_setting.ResetRepoMailToRands)
686688
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled))
687689

688690
m.Group("/user", func() {

services/mailer/incoming/incoming.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ loop:
255255
}
256256

257257
content := getContentFromMailReader(env)
258+
content.Subject = env.GetHeader("Subject")
258259

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

355357
type Attachment struct {

services/mailer/incoming/incoming_handler.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ package incoming
66
import (
77
"bytes"
88
"context"
9+
"errors"
910
"fmt"
1011

1112
issues_model "code.gitea.io/gitea/models/issues"
1213
access_model "code.gitea.io/gitea/models/perm/access"
1314
repo_model "code.gitea.io/gitea/models/repo"
15+
"code.gitea.io/gitea/models/unit"
1416
user_model "code.gitea.io/gitea/models/user"
1517
"code.gitea.io/gitea/modules/log"
1618
"code.gitea.io/gitea/modules/setting"
@@ -28,8 +30,10 @@ type MailHandler interface {
2830
}
2931

3032
var handlers = map[token.HandlerType]MailHandler{
31-
token.ReplyHandlerType: &ReplyHandler{},
32-
token.UnsubscribeHandlerType: &UnsubscribeHandler{},
33+
token.ReplyHandlerType: &ReplyHandler{},
34+
token.UnsubscribeHandlerType: &UnsubscribeHandler{},
35+
token.NewIssueHandlerType: &NewIssueHandler{},
36+
token.NewPullRequestHandlerType: &NewPullRequest{},
3337
}
3438

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

179183
return fmt.Errorf("unsupported unsubscribe reference: %v", ref)
180184
}
185+
186+
// NewIssueHandler handles new issues
187+
type NewIssueHandler struct{}
188+
189+
func (h *NewIssueHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error {
190+
if doer == nil {
191+
return util.NewInvalidArgumentErrorf("doer can't be nil")
192+
}
193+
194+
ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload)
195+
if err != nil {
196+
return err
197+
}
198+
199+
var repo *repo_model.Repository
200+
201+
switch r := ref.(type) {
202+
case *repo_model.Repository:
203+
repo = r
204+
default:
205+
return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref)
206+
}
207+
208+
if util.IsEmptyString(content.Subject) {
209+
return nil
210+
}
211+
212+
perm, err := access_model.GetUserRepoPermission(ctx, repo, doer)
213+
if err != nil {
214+
return err
215+
}
216+
if !perm.CanRead(unit.TypeIssues) {
217+
return nil
218+
}
219+
220+
attachmentIDs := make([]string, 0, len(content.Attachments))
221+
if setting.Attachment.Enabled {
222+
for _, attachment := range content.Attachments {
223+
a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{
224+
Name: attachment.Name,
225+
UploaderID: doer.ID,
226+
RepoID: repo.ID,
227+
})
228+
if err != nil {
229+
if upload.IsErrFileTypeForbidden(err) {
230+
log.Info("NewIssueHandler: Skipping disallowed attachment type: %s", attachment.Name)
231+
continue
232+
}
233+
return err
234+
}
235+
attachmentIDs = append(attachmentIDs, a.UUID)
236+
}
237+
}
238+
239+
issue := &issues_model.Issue{
240+
RepoID: repo.ID,
241+
Repo: repo,
242+
Title: content.Subject,
243+
PosterID: doer.ID,
244+
Poster: doer,
245+
Content: content.Content,
246+
}
247+
248+
if err := issue_service.NewIssue(ctx, repo, issue, []int64{}, attachmentIDs, []int64{}, 0); err != nil {
249+
log.Warn("NewIssueHandler: Failed to create issue: %v", err)
250+
}
251+
252+
return nil
253+
}
254+
255+
// NewPullRequest handles new pull requests
256+
type NewPullRequest struct{}
257+
258+
func (h *NewPullRequest) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error {
259+
return errors.New("not implemented")
260+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package incoming
5+
6+
import (
7+
"context"
8+
"strings"
9+
10+
repo_model "code.gitea.io/gitea/models/repo"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/setting"
13+
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
14+
"code.gitea.io/gitea/services/mailer/token"
15+
)
16+
17+
func GenerateMailToRepoURL(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, event user_model.RepositoryRandsType) (string, string, error) {
18+
_, err := doer.GetOrCreateRandsForRepository(ctx, repo.ID, event)
19+
if err != nil {
20+
return "", "", err
21+
}
22+
23+
payload, err := incoming_payload.CreateReferencePayload(&incoming_payload.ReferenceRepository{
24+
RepositoryID: repo.ID,
25+
ActionType: incoming_payload.ReferenceRepositoryActionTypeNewIssue,
26+
})
27+
if err != nil {
28+
return "", "", err
29+
}
30+
31+
token, err := token.CreateToken(ctx, token.NewIssueHandlerType, doer, payload)
32+
if err != nil {
33+
return "", "", err
34+
}
35+
36+
mailToAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
37+
return token, mailToAddress, nil
38+
}

0 commit comments

Comments
 (0)