add creating new issue by email support

Signed-off-by: a1012112796 <1012112796@qq.com>
This commit is contained in:
a1012112796 2025-02-12 12:30:02 +08:00
parent f58f5bb3d8
commit da19f04a6f
No known key found for this signature in database
GPG Key ID: E5FB19032C2C2A64
17 changed files with 414 additions and 12 deletions

View File

@ -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
}

View File

@ -3,6 +3,8 @@
package user
import "fmt"
const (
// SettingsKeyHiddenCommentTypes is the setting key for hidden comment types
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
@ -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))
}

View File

@ -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

View File

@ -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"
)
@ -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
}

View File

@ -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)
}

View File

@ -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})
}

View File

@ -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() {

View File

@ -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)
@ -350,6 +351,7 @@ func searchTokenInAddresses(addresses []*net_mail.Address) string {
type MailContent struct {
Content string
Attachments []*Attachment
Subject string
}
type Attachment struct {

View File

@ -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"
@ -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
@ -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")
}

View File

@ -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
}

View File

@ -7,6 +7,8 @@ import (
"context"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
)
@ -17,8 +19,22 @@ type payloadReferenceType byte
const (
payloadReferenceIssue payloadReferenceType = iota
payloadReferenceComment
payloadReferenceNewIssue
payloadReferenceNewPullRequest
)
type ReferenceRepositoryActionType int64
const (
ReferenceRepositoryActionTypeNewIssue ReferenceRepositoryActionType = iota
ReferenceRepositoryActionTypeNewPullRequest
)
type ReferenceRepository struct {
RepositoryID int64
ActionType ReferenceRepositoryActionType
}
// CreateReferencePayload creates data which GetReferenceFromPayload resolves to the reference again.
func CreateReferencePayload(reference any) ([]byte, error) {
var refType payloadReferenceType
@ -31,6 +47,17 @@ func CreateReferencePayload(reference any) ([]byte, error) {
case *issues_model.Comment:
refType = payloadReferenceComment
refID = r.ID
case *ReferenceRepository:
switch r.ActionType {
case ReferenceRepositoryActionTypeNewIssue:
refType = payloadReferenceNewIssue
refID = r.RepositoryID
case ReferenceRepositoryActionTypeNewPullRequest:
refType = payloadReferenceNewPullRequest
refID = r.RepositoryID
default:
return nil, util.NewInvalidArgumentErrorf("unsupported repository reference action type: %d", r.ActionType)
}
default:
return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", r)
}
@ -64,7 +91,41 @@ func GetReferenceFromPayload(ctx context.Context, payload []byte) (any, error) {
return issues_model.GetIssueByID(ctx, id)
case payloadReferenceComment:
return issues_model.GetCommentByID(ctx, id)
case payloadReferenceNewIssue:
return repo_model.GetRepositoryByID(ctx, id)
case payloadReferenceNewPullRequest:
return repo_model.GetRepositoryByID(ctx, id)
default:
return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", ref)
}
}
func GetRandsFromPayload(ctx context.Context, doer *user_model.User, payload []byte) []byte {
if len(payload) < 1 {
return []byte{}
}
if payload[0] != replyPayloadVersion1 {
return []byte{}
}
var ref payloadReferenceType
var id int64
if err := util.UnpackData(payload[1:], &ref, &id); err != nil {
return []byte{}
}
switch ref {
case payloadReferenceIssue:
return []byte(doer.Rands)
case payloadReferenceComment:
return []byte(doer.Rands)
case payloadReferenceNewIssue:
rands, _ := user_model.GetRandsForRepository(ctx, doer.ID, id, user_model.RepositoryRandsTypeNewIssue)
return []byte(rands)
case payloadReferenceNewPullRequest:
return []byte{}
default:
return []byte{}
}
}

View File

@ -325,7 +325,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
if setting.IncomingEmail.Enabled {
if replyPayload != nil {
token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
token, err := token.CreateToken(ctx, token.ReplyHandlerType, recipient, replyPayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
@ -337,7 +337,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
}
}
token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
token, err := token.CreateToken(ctx, token.UnsubscribeHandlerType, recipient, unsubscribePayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {

View File

@ -13,6 +13,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
)
// A token is a verifiable container describing an action.
@ -34,6 +35,8 @@ const (
UnknownHandlerType HandlerType = iota
ReplyHandlerType
UnsubscribeHandlerType
NewIssueHandlerType
NewPullRequestHandlerType
)
var encodingWithoutPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
@ -51,7 +54,7 @@ func (err *ErrToken) Unwrap() error {
}
// CreateToken creates a token for the action/user tuple
func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, error) {
func CreateToken(ctx context.Context, ht HandlerType, user *user_model.User, data []byte) (string, error) {
payload, err := util.PackData(
time.Now().AddDate(tokenLifetimeInYears, 0, 0).Unix(),
ht,
@ -63,7 +66,7 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er
packagedData, err := util.PackData(
user.ID,
generateHmac([]byte(user.Rands), payload),
generateHmac(incoming_payload.GetRandsFromPayload(ctx, user, data), payload),
payload,
)
if err != nil {
@ -100,10 +103,6 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U
return UnknownHandlerType, nil, nil, err
}
if !crypto_hmac.Equal(hmac, generateHmac([]byte(user.Rands), payload)) {
return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"}
}
var expiresUnix int64
var handlerType HandlerType
var innerPayload []byte
@ -111,6 +110,10 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U
return UnknownHandlerType, nil, nil, err
}
if !crypto_hmac.Equal(hmac, generateHmac(incoming_payload.GetRandsFromPayload(ctx, user, innerPayload), payload)) {
return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"}
}
if time.Unix(expiresUnix, 0).Before(time.Now()) {
return UnknownHandlerType, nil, nil, &ErrToken{"token expired"}
}

View File

@ -50,6 +50,9 @@
</div>
</div>
{{template "shared/issuelist" dict "." . "listType" "repo"}}
{{if and .PageIsIssueList .MailToIssueEnabled}}
{{template "repo/issue/mailto_module" dict "." .}}
{{end}}
</div>
</div>
{{template "base/footer" .}}

View File

@ -0,0 +1,24 @@
<div class="tw-flex tw-justify-center tw-mb-4">
<div class="ui small modal get-mailto-addr" id="get-mailto-addr" data-reset-url="{{.MailToIssueTokenResetUrl}}">
<div class="header">{{ctx.Locale.Tr "repo.issues.mailto_modal.title"}}</div>
<div class="content tw-flex tw-flex-col">
<div>{{ctx.Locale.Tr "repo.issues.mailto_modal.desc_1"}}</div>
<div class="ui action input mailto-buttons-combo tw-p-2">
<input size="60" class="repo-mailto-url" value="{{.MailToIssueAddress}}" readonly>
<button class="ui small icon button" data-clipboard-target=".repo-mailto-url" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">
{{svg "octicon-copy" 14}}
</button>
<a data-tooltip-content="{{repo.issues.mailto_modal.send_mail}}" class="ui small icon button send-mail-link" href="{{.MailToIssueLink}}">
{{svg "octicon-mail"}}
</a>
</div>
<div>{{ctx.Locale.Tr "repo.issues.mailto_modal.desc_2"}}</div>
<div>{{ctx.Locale.Tr "repo.issues.mailto_modal.desc_3" "reset-get-mailto-addr"}}</div>
</div>
</div>
<div class="tw-justify-center">
<button class="btn show-modal show-get-mailto-addr" data-modal="#get-mailto-addr">
{{ctx.Locale.Tr "repo.issues.mailto_modal.mailto_link"}}
</button>
</div>
</div>

View File

@ -63,7 +63,7 @@ func TestIncomingEmail(t *testing.T) {
payload := []byte{1, 2, 3, 4, 5}
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
token, err := token_service.CreateToken(db.DefaultContext, token_service.ReplyHandlerType, user, payload)
assert.NoError(t, err)
assert.NotEmpty(t, token)
@ -186,7 +186,7 @@ func TestIncomingEmail(t *testing.T) {
payload, err := incoming_payload.CreateReferencePayload(issue)
assert.NoError(t, err)
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
token, err := token_service.CreateToken(db.DefaultContext, token_service.ReplyHandlerType, user, payload)
assert.NoError(t, err)
msg := sender_service.NewMessageFrom(

View File

@ -223,6 +223,31 @@ async function initIssuePinSort() {
});
}
function initGetMailToAddrModal() {
const modal = document.querySelector('.modal.get-mailto-addr');
if (modal === null) return;
const url = modal.getAttribute('data-reset-url');
const input = modal.querySelector<HTMLInputElement>('.repo-mailto-url');
const buttonReset = modal.querySelector<HTMLAnchorElement>('.reset-get-mailto-addr');
const sendMailLink = modal.querySelector<HTMLAnchorElement>('.send-mail-link');
buttonReset.addEventListener('click', async (e) => {
e.preventDefault();
const rsp = await POST(url);
if (rsp.status !== 200) {
return;
}
const data = await rsp.json();
input.value = data.url;
sendMailLink.href = `mailto:${data.url}`;
});
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
@ -232,4 +257,5 @@ export function initRepoIssueList() {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
initGetMailToAddrModal();
}