diff --git a/cmd/web.go b/cmd/web.go index 8465f1e4ff..0f54bf8057 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -78,7 +78,7 @@ func checkVersion() { // Check dependency version. checkers := []VerChecker{ - {"github.com/go-xorm/xorm", func() string { return xorm.Version }, "0.5.5.0711"}, + {"github.com/go-xorm/xorm", func() string { return xorm.Version }, "0.5.5"}, {"github.com/go-macaron/binding", binding.Version, "0.3.2"}, {"github.com/go-macaron/cache", cache.Version, "0.1.2"}, {"github.com/go-macaron/csrf", csrf.Version, "0.1.0"}, @@ -86,7 +86,7 @@ func checkVersion() { {"github.com/go-macaron/session", session.Version, "0.1.6"}, {"github.com/go-macaron/toolbox", toolbox.Version, "0.1.0"}, {"gopkg.in/ini.v1", ini.Version, "1.8.4"}, - {"gopkg.in/macaron.v1", macaron.Version, "1.1.2"}, + {"gopkg.in/macaron.v1", macaron.Version, "1.1.4"}, {"github.com/gogits/git-module", git.Version, "0.3.2"}, {"github.com/gogits/go-gogs-client", gogs.Version, "0.7.4"}, } @@ -126,12 +126,16 @@ func newMacaron() *macaron.Macaron { SkipLogging: setting.DisableRouterLog, }, )) + + funcMap := template.NewFuncMap() m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: path.Join(setting.StaticRootPath, "templates"), AppendDirectories: []string{path.Join(setting.CustomPath, "templates")}, - Funcs: template.NewFuncMap(), + Funcs: funcMap, IndentJSON: macaron.Env != macaron.PROD, })) + models.InitMailRender(path.Join(setting.StaticRootPath, "templates/mail"), + path.Join(setting.CustomPath, "templates/mail"), funcMap) localeNames, err := bindata.AssetDir("conf/locale") if err != nil { diff --git a/models/issue.go b/models/issue.go index b161193118..a0240ff3a0 100644 --- a/models/issue.go +++ b/models/issue.go @@ -73,6 +73,15 @@ func (i *Issue) BeforeUpdate() { i.DeadlineUnix = i.Deadline.UTC().Unix() } +func (issue *Issue) loadAttributes() (err error) { + issue.Repo, err = GetRepositoryByID(issue.RepoID) + if err != nil { + return fmt.Errorf("GetRepositoryByID: %v", err) + } + + return nil +} + func (i *Issue) AfterSet(colName string, _ xorm.Cell) { var err error switch colName { @@ -146,6 +155,10 @@ func (i *Issue) State() string { return "open" } +func (issue *Issue) FullLink() string { + return fmt.Sprintf("%s/issues/%d", issue.Repo.FullLink(), issue.Index) +} + // IsPoster returns true if given user by ID is the poster. func (i *Issue) IsPoster(uid int64) bool { return i.PosterID == uid @@ -390,7 +403,7 @@ func newIssue(e *xorm.Session, repo *Repository, issue *Issue, labelIDs []int64, } } - return nil + return issue.loadAttributes() } // NewIssue creates new issue with labels for repository. @@ -422,7 +435,9 @@ func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string) IsPrivate: repo.IsPrivate, } if err = NotifyWatchers(act); err != nil { - log.Error(4, "notifyWatchers: %v", err) + log.Error(4, "NotifyWatchers: %v", err) + } else if err = issue.MailParticipants(); err != nil { + log.Error(4, "MailParticipants: %v", err) } return nil @@ -451,8 +466,7 @@ func GetIssueByRef(ref string) (*Issue, error) { return nil, err } - issue.Repo = repo - return issue, nil + return issue, issue.loadAttributes() } // GetIssueByIndex returns issue by given index in repository. @@ -467,7 +481,7 @@ func GetIssueByIndex(repoID, index int64) (*Issue, error) { } else if !has { return nil, ErrIssueNotExist{0, repoID, index} } - return issue, nil + return issue, issue.loadAttributes() } // GetIssueByID returns an issue by given ID. @@ -479,7 +493,7 @@ func GetIssueByID(id int64) (*Issue, error) { } else if !has { return nil, ErrIssueNotExist{id, 0, 0} } - return issue, nil + return issue, issue.loadAttributes() } type IssuesOptions struct { @@ -700,42 +714,44 @@ func GetIssueUserPairsByMode(uid, rid int64, isClosed bool, page, filterMode int return ius, err } -func UpdateMentions(userNames []string, issueId int64) error { - for i := range userNames { - userNames[i] = strings.ToLower(userNames[i]) - } - users := make([]*User, 0, len(userNames)) - - if err := x.Where("lower_name IN (?)", strings.Join(userNames, "\",\"")).OrderBy("lower_name ASC").Find(&users); err != nil { - return err +// UpdateIssueMentions extracts mentioned people from content and +// updates issue-user relations for them. +func UpdateIssueMentions(issueID int64, mentions []string) error { + if len(mentions) == 0 { + return nil } - ids := make([]int64, 0, len(userNames)) + for i := range mentions { + mentions[i] = strings.ToLower(mentions[i]) + } + users := make([]*User, 0, len(mentions)) + + if err := x.In("lower_name", mentions).Asc("lower_name").Find(&users); err != nil { + return fmt.Errorf("find mentioned users: %v", err) + } + + ids := make([]int64, 0, len(mentions)) for _, user := range users { ids = append(ids, user.Id) - if !user.IsOrganization() { + if !user.IsOrganization() || user.NumMembers == 0 { continue } - if user.NumMembers == 0 { - continue - } - - tempIds := make([]int64, 0, user.NumMembers) - orgUsers, err := GetOrgUsersByOrgId(user.Id) + memberIDs := make([]int64, 0, user.NumMembers) + orgUsers, err := GetOrgUsersByOrgID(user.Id) if err != nil { - return err + return fmt.Errorf("GetOrgUsersByOrgID [%d]: %v", user.Id, err) } for _, orgUser := range orgUsers { - tempIds = append(tempIds, orgUser.ID) + memberIDs = append(memberIDs, orgUser.ID) } - ids = append(ids, tempIds...) + ids = append(ids, memberIDs...) } - if err := UpdateIssueUsersByMentions(ids, issueId); err != nil { - return err + if err := UpdateIssueUsersByMentions(issueID, ids); err != nil { + return fmt.Errorf("UpdateIssueUsersByMentions: %v", err) } return nil @@ -973,9 +989,12 @@ func UpdateIssueUserByRead(uid, issueID int64) error { } // UpdateIssueUsersByMentions updates issue-user pairs by mentioning. -func UpdateIssueUsersByMentions(uids []int64, iid int64) error { +func UpdateIssueUsersByMentions(issueID int64, uids []int64) error { for _, uid := range uids { - iu := &IssueUser{UID: uid, IssueID: iid} + iu := &IssueUser{ + UID: uid, + IssueID: issueID, + } has, err := x.Get(iu) if err != nil { return err diff --git a/models/issue_comment.go b/models/issue_comment.go index 7c96a7d34e..d1b7cb8d14 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -13,6 +13,7 @@ import ( "github.com/go-xorm/xorm" "github.com/gogits/gogs/modules/log" + "github.com/gogits/gogs/modules/markdown" ) // CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. @@ -113,10 +114,34 @@ func (c *Comment) EventTag() string { return "event-" + com.ToStr(c.ID) } +// MailParticipants sends new comment emails to repository watchers +// and mentioned people. +func (cmt *Comment) MailParticipants(opType ActionType, issue *Issue) (err error) { + mentions := markdown.FindAllMentions(cmt.Content) + if err = UpdateIssueMentions(cmt.IssueID, mentions); err != nil { + return fmt.Errorf("UpdateIssueMentions [%d]: %v", cmt.IssueID, err) + } + + switch opType { + case ACTION_COMMENT_ISSUE: + issue.Content = cmt.Content + case ACTION_CLOSE_ISSUE: + issue.Content = fmt.Sprintf("Closed #%d", issue.Index) + case ACTION_REOPEN_ISSUE: + issue.Content = fmt.Sprintf("Reopened #%d", issue.Index) + } + if err = mailIssueCommentToParticipants(issue, cmt.Poster, mentions); err != nil { + log.Error(4, "mailIssueCommentToParticipants: %v", err) + } + + return nil +} + func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { comment := &Comment{ Type: opts.Type, PosterID: opts.Doer.Id, + Poster: opts.Doer, IssueID: opts.Issue.ID, CommitID: opts.CommitID, CommitSHA: opts.CommitSHA, @@ -157,7 +182,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err if IsErrAttachmentNotExist(err) { continue } - return nil, fmt.Errorf("getAttachmentByUUID[%s]: %v", uuid, err) + return nil, fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) } attachments = append(attachments, attach) } @@ -167,7 +192,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err attachments[i].CommentID = comment.ID // No assign value could be 0, so ignore AllCols(). if _, err = e.Id(attachments[i].ID).Update(attachments[i]); err != nil { - return nil, fmt.Errorf("update attachment[%d]: %v", attachments[i].ID, err) + return nil, fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) } } @@ -200,13 +225,15 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err if err != nil { return nil, err } + } - // Notify watchers for whatever action comes in, ignore if no action type + // Notify watchers for whatever action comes in, ignore if no action type. if act.OpType > 0 { if err = notifyWatchers(e, act); err != nil { - return nil, fmt.Errorf("notifyWatchers: %v", err) + log.Error(4, "notifyWatchers: %v", err) } + comment.MailParticipants(act.OpType, opts.Issue) } return comment, nil diff --git a/models/issue_mail.go b/models/issue_mail.go new file mode 100644 index 0000000000..3260de271c --- /dev/null +++ b/models/issue_mail.go @@ -0,0 +1,81 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "fmt" + + "github.com/Unknwon/com" + + "github.com/gogits/gogs/modules/log" + "github.com/gogits/gogs/modules/markdown" + "github.com/gogits/gogs/modules/setting" +) + +func (issue *Issue) MailSubject() string { + return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.Name, issue.Name, issue.Index) +} + +// mailIssueCommentToParticipants can be used for both new issue creation and comment. +func mailIssueCommentToParticipants(issue *Issue, doer *User, mentions []string) error { + if !setting.Service.EnableNotifyMail { + return nil + } + + // Mail wahtcers. + watchers, err := GetWatchers(issue.RepoID) + if err != nil { + return fmt.Errorf("GetWatchers [%d]: %v", issue.RepoID, err) + } + + tos := make([]string, 0, len(watchers)) // List of email addresses. + names := make([]string, 0, len(watchers)) + for i := range watchers { + if watchers[i].UserID == doer.Id { + continue + } + + to, err := GetUserByID(watchers[i].UserID) + if err != nil { + return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err) + } + if to.IsOrganization() { + continue + } + + tos = append(tos, to.Email) + names = append(names, to.Name) + } + SendIssueCommentMail(issue, doer, tos) + + // Mail mentioned people and exclude watchers. + names = append(names, doer.Name) + tos = make([]string, 0, len(mentions)) // list of user names. + for i := range mentions { + if com.IsSliceContainsStr(names, mentions[i]) { + continue + } + + tos = append(tos, mentions[i]) + } + SendIssueMentionMail(issue, doer, GetUserEmailsByNames(tos)) + + return nil +} + +// MailParticipants sends new issue thread created emails to repository watchers +// and mentioned people. +func (issue *Issue) MailParticipants() (err error) { + mentions := markdown.FindAllMentions(issue.Content) + if err = UpdateIssueMentions(issue.ID, mentions); err != nil { + return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err) + } + + if err = mailIssueCommentToParticipants(issue, issue.Poster, mentions); err != nil { + log.Error(4, "mailIssueCommentToParticipants: %v", err) + } + + return nil +} diff --git a/models/mail.go b/models/mail.go new file mode 100644 index 0000000000..9f34bbf384 --- /dev/null +++ b/models/mail.go @@ -0,0 +1,183 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "fmt" + "html/template" + "path" + + "gopkg.in/gomail.v2" + "gopkg.in/macaron.v1" + + "github.com/gogits/gogs/modules/base" + "github.com/gogits/gogs/modules/log" + "github.com/gogits/gogs/modules/mailer" + "github.com/gogits/gogs/modules/markdown" + "github.com/gogits/gogs/modules/setting" +) + +const ( + MAIL_AUTH_ACTIVATE base.TplName = "auth/activate" + MAIL_AUTH_ACTIVATE_EMAIL base.TplName = "auth/activate_email" + MAIL_AUTH_RESET_PASSWORD base.TplName = "auth/reset_passwd" + MAIL_AUTH_REGISTER_NOTIFY base.TplName = "auth/register_notify" + + MAIL_ISSUE_COMMENT base.TplName = "issue/comment" + MAIL_ISSUE_MENTION base.TplName = "issue/mention" + + MAIL_NOTIFY_COLLABORATOR base.TplName = "notify/collaborator" +) + +type MailRender interface { + HTMLString(string, interface{}, ...macaron.HTMLOptions) (string, error) +} + +var mailRender MailRender + +func InitMailRender(dir, appendDir string, funcMap []template.FuncMap) { + opt := &macaron.RenderOptions{ + Directory: dir, + AppendDirectories: []string{appendDir}, + Funcs: funcMap, + Extensions: []string{".tmpl", ".html"}, + } + ts := macaron.NewTemplateSet() + ts.Set(macaron.DEFAULT_TPL_SET_NAME, opt) + + mailRender = &macaron.TplRender{ + TemplateSet: ts, + Opt: opt, + } +} + +func SendTestMail(email string) error { + return gomail.Send(&mailer.Sender{}, mailer.NewMessage([]string{email}, "Gogs Test Email!", "Gogs Test Email!").Message) +} + +func SendUserMail(c *macaron.Context, u *User, tpl base.TplName, code, subject, info string) { + data := map[string]interface{}{ + "Username": u.DisplayName(), + "ActiveCodeLives": setting.Service.ActiveCodeLives / 60, + "ResetPwdCodeLives": setting.Service.ResetPwdCodeLives / 60, + "Code": code, + } + body, err := mailRender.HTMLString(string(tpl), data) + if err != nil { + log.Error(3, "HTMLString: %v", err) + return + } + + msg := mailer.NewMessage([]string{u.Email}, subject, body) + msg.Info = fmt.Sprintf("UID: %d, %s", u.Id, info) + + mailer.SendAsync(msg) +} + +func SendActivateAccountMail(c *macaron.Context, u *User) { + SendUserMail(c, u, MAIL_AUTH_ACTIVATE, u.GenerateActivateCode(), c.Tr("mail.activate_account"), "activate account") +} + +func SendResetPasswordMail(c *macaron.Context, u *User) { + SendUserMail(c, u, MAIL_AUTH_RESET_PASSWORD, u.GenerateActivateCode(), c.Tr("mail.reset_password"), "reset password") +} + +// SendActivateAccountMail sends confirmation email. +func SendActivateEmailMail(c *macaron.Context, u *User, email *EmailAddress) { + data := map[string]interface{}{ + "Username": u.DisplayName(), + "ActiveCodeLives": setting.Service.ActiveCodeLives / 60, + "Code": u.GenerateEmailActivateCode(email.Email), + "Email": email.Email, + } + body, err := mailRender.HTMLString(string(MAIL_AUTH_ACTIVATE_EMAIL), data) + if err != nil { + log.Error(3, "HTMLString: %v", err) + return + } + + msg := mailer.NewMessage([]string{email.Email}, c.Tr("mail.activate_email"), body) + msg.Info = fmt.Sprintf("UID: %d, activate email", u.Id) + + mailer.SendAsync(msg) +} + +// SendRegisterNotifyMail triggers a notify e-mail by admin created a account. +func SendRegisterNotifyMail(c *macaron.Context, u *User) { + data := map[string]interface{}{ + "Username": u.DisplayName(), + } + body, err := mailRender.HTMLString(string(MAIL_AUTH_REGISTER_NOTIFY), data) + if err != nil { + log.Error(3, "HTMLString: %v", err) + return + } + + msg := mailer.NewMessage([]string{u.Email}, c.Tr("mail.register_notify"), body) + msg.Info = fmt.Sprintf("UID: %d, registration notify", u.Id) + + mailer.SendAsync(msg) +} + +// SendCollaboratorMail sends mail notification to new collaborator. +func SendCollaboratorMail(u, doer *User, repo *Repository) { + repoName := path.Join(repo.Owner.Name, repo.Name) + subject := fmt.Sprintf("%s added you to %s", doer.DisplayName(), repoName) + + data := map[string]interface{}{ + "Subject": subject, + "RepoName": repoName, + "Link": repo.FullLink(), + } + body, err := mailRender.HTMLString(string(MAIL_NOTIFY_COLLABORATOR), data) + if err != nil { + log.Error(3, "HTMLString: %v", err) + return + } + + msg := mailer.NewMessage([]string{u.Email}, subject, body) + msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.Id) + + mailer.SendAsync(msg) +} + +func composeTplData(subject, body, link string) map[string]interface{} { + data := make(map[string]interface{}, 10) + data["Subject"] = subject + data["Body"] = body + data["Link"] = link + return data +} + +func composeIssueMessage(issue *Issue, doer *User, tplName base.TplName, tos []string, info string) *mailer.Message { + subject := issue.MailSubject() + body := string(markdown.RenderSpecialLink([]byte(issue.Content), issue.Repo.FullLink(), issue.Repo.ComposeMetas())) + data := composeTplData(subject, body, issue.FullLink()) + data["Doer"] = doer + content, err := mailRender.HTMLString(string(tplName), data) + if err != nil { + log.Error(3, "HTMLString (%s): %v", tplName, err) + } + msg := mailer.NewMessage(tos, subject, content) + msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) + return msg +} + +// SendIssueCommentMail composes and sends issue comment emails to target receivers. +func SendIssueCommentMail(issue *Issue, doer *User, tos []string) { + if len(tos) == 0 { + return + } + + mailer.SendAsync(composeIssueMessage(issue, doer, MAIL_ISSUE_COMMENT, tos, "issue comment")) +} + +// SendIssueMentionMail composes and sends issue mention emails to target receivers. +func SendIssueMentionMail(issue *Issue, doer *User, tos []string) { + if len(tos) == 0 { + return + } + mailer.SendAsync(composeIssueMessage(issue, doer, MAIL_ISSUE_MENTION, tos, "issue mention")) +} diff --git a/models/org.go b/models/org.go index bac3ad25ca..7f659b36a0 100644 --- a/models/org.go +++ b/models/org.go @@ -58,7 +58,7 @@ func (org *User) GetTeams() error { // GetMembers returns all members of organization. func (org *User) GetMembers() error { - ous, err := GetOrgUsersByOrgId(org.Id) + ous, err := GetOrgUsersByOrgID(org.Id) if err != nil { return err } @@ -306,10 +306,10 @@ func GetOrgUsersByUserID(uid int64, all bool) ([]*OrgUser, error) { return ous, err } -// GetOrgUsersByOrgId returns all organization-user relations by organization ID. -func GetOrgUsersByOrgId(orgId int64) ([]*OrgUser, error) { +// GetOrgUsersByOrgID returns all organization-user relations by organization ID. +func GetOrgUsersByOrgID(orgID int64) ([]*OrgUser, error) { ous := make([]*OrgUser, 0, 10) - err := x.Where("org_id=?", orgId).Find(&ous) + err := x.Where("org_id=?", orgID).Find(&ous) return ous, err } diff --git a/models/pull.go b/models/pull.go index 8ef0cc643a..6380bbbd2d 100644 --- a/models/pull.go +++ b/models/pull.go @@ -342,9 +342,6 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str RepoName: repo.Name, IsPrivate: repo.IsPrivate, } - if err = notifyWatchers(sess, act); err != nil { - return err - } pr.Index = pull.Index if err = repo.SavePatch(pr.Index, patch); err != nil { @@ -364,7 +361,17 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str return fmt.Errorf("insert pull repo: %v", err) } - return sess.Commit() + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if err = NotifyWatchers(act); err != nil { + log.Error(4, "NotifyWatchers: %v", err) + } else if err = pull.MailParticipants(); err != nil { + log.Error(4, "MailParticipants: %v", err) + } + + return nil } // GetUnmergedPullRequest returnss a pull request that is open and has not been merged diff --git a/modules/mailer/mail.go b/modules/mailer/mail.go deleted file mode 100644 index 440ffdc01f..0000000000 --- a/modules/mailer/mail.go +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2014 The Gogs Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package mailer - -import ( - "fmt" - "path" - "strings" - - "gopkg.in/gomail.v2" - "gopkg.in/macaron.v1" - - "github.com/gogits/gogs/models" - "github.com/gogits/gogs/modules/base" - "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/markdown" - "github.com/gogits/gogs/modules/setting" -) - -const ( - AUTH_ACTIVATE base.TplName = "mail/auth/activate" - AUTH_ACTIVATE_EMAIL base.TplName = "mail/auth/activate_email" - AUTH_REGISTER_NOTIFY base.TplName = "mail/auth/register_notify" - AUTH_RESET_PASSWORD base.TplName = "mail/auth/reset_passwd" - - NOTIFY_COLLABORATOR base.TplName = "mail/notify/collaborator" - NOTIFY_MENTION base.TplName = "mail/notify/mention" -) - -func ComposeTplData(u *models.User) map[interface{}]interface{} { - data := make(map[interface{}]interface{}, 10) - data["AppName"] = setting.AppName - data["AppVer"] = setting.AppVer - data["AppUrl"] = setting.AppUrl - data["ActiveCodeLives"] = setting.Service.ActiveCodeLives / 60 - data["ResetPwdCodeLives"] = setting.Service.ResetPwdCodeLives / 60 - - if u != nil { - data["User"] = u - } - return data -} - -func SendUserMail(c *macaron.Context, u *models.User, tpl base.TplName, code, subject, info string) { - data := ComposeTplData(u) - data["Code"] = code - body, err := c.HTMLString(string(tpl), data) - if err != nil { - log.Error(4, "HTMLString: %v", err) - return - } - - msg := NewMessage([]string{u.Email}, subject, body) - msg.Info = fmt.Sprintf("UID: %d, %s", u.Id, info) - - SendAsync(msg) -} - -func SendActivateAccountMail(c *macaron.Context, u *models.User) { - SendUserMail(c, u, AUTH_ACTIVATE, u.GenerateActivateCode(), c.Tr("mail.activate_account"), "activate account") -} - -// SendResetPasswordMail sends reset password e-mail. -func SendResetPasswordMail(c *macaron.Context, u *models.User) { - SendUserMail(c, u, AUTH_RESET_PASSWORD, u.GenerateActivateCode(), c.Tr("mail.reset_password"), "reset password") -} - -// SendRegisterNotifyMail triggers a notify e-mail by admin created a account. -func SendRegisterNotifyMail(c *macaron.Context, u *models.User) { - body, err := c.HTMLString(string(AUTH_REGISTER_NOTIFY), ComposeTplData(u)) - if err != nil { - log.Error(4, "HTMLString: %v", err) - return - } - - msg := NewMessage([]string{u.Email}, c.Tr("mail.register_notify"), body) - msg.Info = fmt.Sprintf("UID: %d, registration notify", u.Id) - - SendAsync(msg) -} - -// SendActivateAccountMail sends confirmation e-mail. -func SendActivateEmailMail(c *macaron.Context, u *models.User, email *models.EmailAddress) { - data := ComposeTplData(u) - data["Code"] = u.GenerateEmailActivateCode(email.Email) - data["Email"] = email.Email - body, err := c.HTMLString(string(AUTH_ACTIVATE_EMAIL), data) - if err != nil { - log.Error(4, "HTMLString: %v", err) - return - } - - msg := NewMessage([]string{email.Email}, c.Tr("mail.activate_email"), body) - msg.Info = fmt.Sprintf("UID: %d, activate email", u.Id) - - SendAsync(msg) -} - -// SendIssueNotifyMail sends mail notification of all watchers of repository. -func SendIssueNotifyMail(u, owner *models.User, repo *models.Repository, issue *models.Issue) ([]string, error) { - ws, err := models.GetWatchers(repo.ID) - if err != nil { - return nil, fmt.Errorf("GetWatchers[%d]: %v", repo.ID, err) - } - - tos := make([]string, 0, len(ws)) - for i := range ws { - uid := ws[i].UserID - if u.Id == uid { - continue - } - to, err := models.GetUserByID(uid) - if err != nil { - return nil, fmt.Errorf("GetUserByID: %v", err) - } - if to.IsOrganization() { - continue - } - - tos = append(tos, to.Email) - } - - if len(tos) == 0 { - return tos, nil - } - - subject := fmt.Sprintf("[%s] %s (#%d)", repo.Name, issue.Name, issue.Index) - content := fmt.Sprintf("%s
-
View it on Gogs.", - markdown.RenderSpecialLink([]byte(strings.Replace(issue.Content, "\n", "
", -1)), owner.Name+"/"+repo.Name, repo.ComposeMetas()), - setting.AppUrl, owner.Name, repo.Name, issue.Index) - msg := NewMessage(tos, subject, content) - msg.Info = fmt.Sprintf("Subject: %s, issue notify", subject) - - SendAsync(msg) - return tos, nil -} - -// SendIssueMentionMail sends mail notification for who are mentioned in issue. -func SendIssueMentionMail(r macaron.Render, u, owner *models.User, - repo *models.Repository, issue *models.Issue, tos []string) error { - - if len(tos) == 0 { - return nil - } - - subject := fmt.Sprintf("[%s] %s (#%d)", repo.Name, issue.Name, issue.Index) - - data := ComposeTplData(nil) - data["IssueLink"] = fmt.Sprintf("%s/%s/issues/%d", owner.Name, repo.Name, issue.Index) - data["Subject"] = subject - data["ActUserName"] = u.DisplayName() - data["Content"] = string(markdown.RenderSpecialLink([]byte(issue.Content), owner.Name+"/"+repo.Name, repo.ComposeMetas())) - - body, err := r.HTMLString(string(NOTIFY_MENTION), data) - if err != nil { - return fmt.Errorf("HTMLString: %v", err) - } - - msg := NewMessage(tos, subject, body) - msg.Info = fmt.Sprintf("Subject: %s, issue mention", subject) - - SendAsync(msg) - return nil -} - -// SendCollaboratorMail sends mail notification to new collaborator. -func SendCollaboratorMail(r macaron.Render, u, doer *models.User, repo *models.Repository) error { - subject := fmt.Sprintf("%s added you to %s/%s", doer.Name, repo.Owner.Name, repo.Name) - - data := ComposeTplData(nil) - data["RepoLink"] = path.Join(repo.Owner.Name, repo.Name) - data["Subject"] = subject - - body, err := r.HTMLString(string(NOTIFY_COLLABORATOR), data) - if err != nil { - return fmt.Errorf("HTMLString: %v", err) - } - - msg := NewMessage([]string{u.Email}, subject, body) - msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.Id) - - SendAsync(msg) - return nil -} - -func SendTestMail(email string) error { - return gomail.Send(&Sender{}, NewMessage([]string{email}, "Gogs Test Email!", "Gogs Test Email!").Message) -} diff --git a/modules/mailer/mailer.go b/modules/mailer/mailer.go index 13d80b5648..80a692699e 100644 --- a/modules/mailer/mailer.go +++ b/modules/mailer/mailer.go @@ -28,6 +28,8 @@ type Message struct { // NewMessageFrom creates new mail message object with custom From header. func NewMessageFrom(to []string, from, subject, htmlBody string) *Message { + log.Trace("NewMessageFrom (htmlBody):\n%s", htmlBody) + msg := gomail.NewMessage() msg.SetHeader("From", from) msg.SetHeader("To", to...) @@ -196,7 +198,7 @@ func processMailQueue() { case msg := <-mailQueue: log.Trace("New e-mail sending request %s: %s", msg.GetHeader("To"), msg.Info) if err := gomail.Send(sender, msg.Message); err != nil { - log.Error(4, "Fail to send e-mails %s: %s - %v", msg.GetHeader("To"), msg.Info, err) + log.Error(3, "Fail to send emails %s: %s - %v", msg.GetHeader("To"), msg.Info, err) } else { log.Trace("E-mails sent %s: %s", msg.GetHeader("To"), msg.Info) } diff --git a/modules/markdown/markdown.go b/modules/markdown/markdown.go index 99a4323d99..d49c14d1f9 100644 --- a/modules/markdown/markdown.go +++ b/modules/markdown/markdown.go @@ -93,6 +93,16 @@ var ( Sha1CurrentPattern = regexp.MustCompile(`\b[0-9a-f]{40}\b`) ) +// FindAllMentions matches mention patterns in given content +// and returns a list of found user names without @ prefix. +func FindAllMentions(content string) []string { + mentions := MentionPattern.FindAllString(content, -1) + for i := range mentions { + mentions[i] = strings.TrimSpace(mentions[i])[1:] // Strip @ character + } + return mentions +} + // Renderer is a extended version of underlying render object. type Renderer struct { blackfriday.Renderer @@ -202,6 +212,9 @@ func (r *Renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byt // cutoutVerbosePrefix cutouts URL prefix including sub-path to // return a clean unified string of request URL path. func cutoutVerbosePrefix(prefix string) string { + if len(prefix) == 0 || prefix[0] != '/' { + return prefix + } count := 0 for i := 0; i < len(prefix); i++ { if prefix[i] == '/' { diff --git a/routers/admin/admin.go b/routers/admin/admin.go index 1608b73b11..bc850b638e 100644 --- a/routers/admin/admin.go +++ b/routers/admin/admin.go @@ -17,7 +17,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/cron" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/process" "github.com/gogits/gogs/modules/setting" ) @@ -178,7 +177,7 @@ func Dashboard(ctx *context.Context) { func SendTestMail(ctx *context.Context) { email := ctx.Query("email") // Send a test email to the user's email address and redirect back to Config - if err := mailer.SendTestMail(email); err != nil { + if err := models.SendTestMail(email); err != nil { ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err)) } else { ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email)) diff --git a/routers/admin/users.go b/routers/admin/users.go index 76fbcd1683..2eca243b6c 100644 --- a/routers/admin/users.go +++ b/routers/admin/users.go @@ -14,7 +14,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/setting" "github.com/gogits/gogs/routers" ) @@ -115,9 +114,9 @@ func NewUserPost(ctx *context.Context, form auth.AdminCrateUserForm) { } log.Trace("Account created by admin (%s): %s", ctx.User.Name, u.Name) - // Send e-mail notification. + // Send email notification. if form.SendNotify && setting.MailService != nil { - mailer.SendRegisterNotifyMail(ctx.Context, u) + models.SendRegisterNotifyMail(ctx.Context, u) } ctx.Flash.Success(ctx.Tr("admin.users.new_success", u.Name)) diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index ce13804ffb..4bb24998bb 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -10,7 +10,6 @@ import ( "github.com/gogits/gogs/models" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/setting" "github.com/gogits/gogs/routers/api/v1/convert" "github.com/gogits/gogs/routers/api/v1/user" @@ -64,9 +63,9 @@ func CreateUser(ctx *context.APIContext, form api.CreateUserOption) { } log.Trace("Account created by admin (%s): %s", ctx.User.Name, u.Name) - // Send e-mail notification. + // Send email notification. if form.SendNotify && setting.MailService != nil { - mailer.SendRegisterNotifyMail(ctx.Context.Context, u) + models.SendRegisterNotifyMail(ctx.Context.Context, u) } ctx.JSON(201, convert.ToUser(u)) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index a80adbc356..bf2c8e9ddf 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -14,7 +14,6 @@ import ( "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/setting" "github.com/gogits/gogs/routers/api/v1/convert" - "github.com/gogits/gogs/routers/repo" ) func ListIssues(ctx *context.APIContext) { @@ -80,9 +79,6 @@ func CreateIssue(ctx *context.APIContext, form api.CreateIssueOption) { if err := models.NewIssue(ctx.Repo.Repository, issue, form.Labels, nil); err != nil { ctx.Error(500, "NewIssue", err) return - } else if err := repo.MailWatchersAndMentions(ctx.Context, issue); err != nil { - ctx.Error(500, "MailWatchersAndMentions", err) - return } if form.Closed { diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 4f75532b3d..accf51d1ec 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -22,7 +22,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/markdown" "github.com/gogits/gogs/modules/setting" ) @@ -395,46 +394,6 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm) ([]int64 return labelIDs, milestoneID, assigneeID } -func MailWatchersAndMentions(ctx *context.Context, issue *models.Issue) error { - // Update mentions - mentions := markdown.MentionPattern.FindAllString(issue.Content, -1) - if len(mentions) > 0 { - for i := range mentions { - mentions[i] = strings.TrimSpace(mentions[i])[1:] - } - - if err := models.UpdateMentions(mentions, issue.ID); err != nil { - return fmt.Errorf("UpdateMentions: %v", err) - } - } - - repo := ctx.Repo.Repository - - // Mail watchers and mentions. - if setting.Service.EnableNotifyMail { - tos, err := mailer.SendIssueNotifyMail(ctx.User, ctx.Repo.Owner, repo, issue) - if err != nil { - return fmt.Errorf("SendIssueNotifyMail: %v", err) - } - - tos = append(tos, ctx.User.LowerName) - newTos := make([]string, 0, len(mentions)) - for _, m := range mentions { - if com.IsSliceContainsStr(tos, m) { - continue - } - - newTos = append(newTos, m) - } - if err = mailer.SendIssueMentionMail(ctx.Render, ctx.User, ctx.Repo.Owner, - repo, issue, models.GetUserEmailsByNames(newTos)); err != nil { - return fmt.Errorf("SendIssueMentionMail: %v", err) - } - } - - return nil -} - func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true @@ -471,9 +430,6 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { if err := models.NewIssue(repo, issue, labelIDs, attachments); err != nil { ctx.Handle(500, "NewIssue", err) return - } else if err := MailWatchersAndMentions(ctx, issue); err != nil { - ctx.Handle(500, "MailWatchersAndMentions", err) - return } log.Trace("Issue created: %d/%d", repo.ID, issue.ID) @@ -933,16 +889,6 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) { return } - MailWatchersAndMentions(ctx, &models.Issue{ - ID: issue.ID, - Index: issue.Index, - Name: issue.Name, - Content: form.Content, - }) - if ctx.Written() { - return - } - log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) } @@ -1024,7 +970,6 @@ func UpdateLabel(ctx *context.Context, form auth.CreateLabelForm) { return } - fmt.Println(form.Title, form.Color) l.Name = form.Title l.Color = form.Color if err := models.UpdateLabel(l); err != nil { diff --git a/routers/repo/pull.go b/routers/repo/pull.go index da1ee14996..56245a8766 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -681,9 +681,6 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) } else if err := pullRequest.PushToBaseRepo(); err != nil { ctx.Handle(500, "PushToBaseRepo", err) return - } else if err := MailWatchersAndMentions(ctx, pullIssue); err != nil { - ctx.Handle(500, "MailWatchersAndMentions", err) - return } log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) diff --git a/routers/repo/setting.go b/routers/repo/setting.go index 6fd195aa10..c9236a5331 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -15,7 +15,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/setting" ) @@ -325,10 +324,7 @@ func CollaborationPost(ctx *context.Context) { } if setting.Service.EnableNotifyMail { - if err = mailer.SendCollaboratorMail(ctx.Render, u, ctx.User, ctx.Repo.Repository); err != nil { - ctx.Handle(500, "SendCollaboratorMail", err) - return - } + models.SendCollaboratorMail(u, ctx.User, ctx.Repo.Repository) } ctx.Flash.Success(ctx.Tr("repo.settings.add_collaborator_success")) diff --git a/routers/user/auth.go b/routers/user/auth.go index 9b48357aaa..df79ecbe4c 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -15,7 +15,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/setting" ) @@ -220,9 +219,9 @@ func SignUpPost(ctx *context.Context, cpt *captcha.Captcha, form auth.RegisterFo } } - // Send confirmation e-mail, no need for social account. + // Send confirmation email, no need for social account. if setting.Service.RegisterEmailConfirm && u.Id > 1 { - mailer.SendActivateAccountMail(ctx.Context, u) + models.SendActivateAccountMail(ctx.Context, u) ctx.Data["IsSendRegisterMail"] = true ctx.Data["Email"] = u.Email ctx.Data["Hours"] = setting.Service.ActiveCodeLives / 60 @@ -245,13 +244,13 @@ func Activate(ctx *context.Context) { ctx.Error(404) return } - // Resend confirmation e-mail. + // Resend confirmation email. if setting.Service.RegisterEmailConfirm { if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { ctx.Data["ResendLimited"] = true } else { ctx.Data["Hours"] = setting.Service.ActiveCodeLives / 60 - mailer.SendActivateAccountMail(ctx.Context, ctx.User) + models.SendActivateAccountMail(ctx.Context, ctx.User) if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { log.Error(4, "Set cache(MailResendLimit) fail: %v", err) @@ -355,7 +354,7 @@ func ForgotPasswdPost(ctx *context.Context) { return } - mailer.SendResetPasswordMail(ctx.Context, u) + models.SendResetPasswordMail(ctx.Context, u) if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { log.Error(4, "Set cache(MailResendLimit) fail: %v", err) } diff --git a/routers/user/setting.go b/routers/user/setting.go index 4b62bb11cc..c910cd4e14 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -17,7 +17,6 @@ import ( "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/context" "github.com/gogits/gogs/modules/log" - "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/setting" ) @@ -239,12 +238,12 @@ func SettingsEmailPost(ctx *context.Context, form auth.AddEmailForm) { return } - e := &models.EmailAddress{ + email := &models.EmailAddress{ UID: ctx.User.Id, Email: form.Email, IsActivated: !setting.Service.RegisterEmailConfirm, } - if err := models.AddEmailAddress(e); err != nil { + if err := models.AddEmailAddress(email); err != nil { if models.IsErrEmailAlreadyUsed(err) { ctx.RenderWithErr(ctx.Tr("form.email_been_used"), SETTINGS_EMAILS, &form) return @@ -253,19 +252,19 @@ func SettingsEmailPost(ctx *context.Context, form auth.AddEmailForm) { return } - // Send confirmation e-mail + // Send confirmation email if setting.Service.RegisterEmailConfirm { - mailer.SendActivateEmailMail(ctx.Context, ctx.User, e) + models.SendActivateEmailMail(ctx.Context, ctx.User, email) if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { log.Error(4, "Set cache(MailResendLimit) fail: %v", err) } - ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", e.Email, setting.Service.ActiveCodeLives/60)) + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, setting.Service.ActiveCodeLives/60)) } else { ctx.Flash.Success(ctx.Tr("settings.add_email_success")) } - log.Trace("Email address added: %s", e.Email) + log.Trace("Email address added: %s", email.Email) ctx.Redirect(setting.AppSubUrl + "/user/settings/email") } diff --git a/templates/mail/auth/activate.tmpl b/templates/mail/auth/activate.tmpl index dfbc3ba86b..f03c165694 100644 --- a/templates/mail/auth/activate.tmpl +++ b/templates/mail/auth/activate.tmpl @@ -2,14 +2,14 @@ - {{.User.Name}}, please activate your account + {{.Username}}, please activate your account -

Hi {{.User.Name}}, thanks for registering at {{.AppName}}!

+

Hi {{.Username}}, thanks for registering at {{AppName}}!

Please click the following link to verify your e-mail address within {{.ActiveCodeLives}} hours:

-

{{.AppUrl}}user/activate?code={{.Code}}

+

{{AppUrl}}user/activate?code={{.Code}}

Not working? Try copying and pasting it to your browser.

-

© 2015 Gogs: Go Git Service

+

© 2016 {{AppName}}

diff --git a/templates/mail/auth/activate_email.tmpl b/templates/mail/auth/activate_email.tmpl index 73d7f616ef..65cb5045b3 100644 --- a/templates/mail/auth/activate_email.tmpl +++ b/templates/mail/auth/activate_email.tmpl @@ -2,14 +2,14 @@ - {{.User.Name}}, please verify your e-mail address + {{.Username}}, please verify your e-mail address -

Hi {{.User.Name}},

-

Please click the following link to verify your e-mail address within {{.ActiveCodeLives}} hours:

-

{{.AppUrl}}user/activate_email?code={{.Code}}&email={{.Email}}

+

Hi {{.Username}},

+

Please click the following link to verify your email address within {{.ActiveCodeLives}} hours:

+

{{AppUrl}}user/activate_email?code={{.Code}}&email={{.Email}}

Not working? Try copying and pasting it to your browser.

-

© 2015 Gogs: Go Git Service

+

© 2016 {{AppName}}

diff --git a/templates/mail/auth/register_notify.tmpl b/templates/mail/auth/register_notify.tmpl index 864ed55acd..75a4356459 100644 --- a/templates/mail/auth/register_notify.tmpl +++ b/templates/mail/auth/register_notify.tmpl @@ -2,13 +2,13 @@ - {{.User.Name}}, welcome to {{.AppName}} + {{.Username}}, welcome to {{AppName}} -

Hi {{.User.Name}}, this is your registration confirmation email for {{.AppName}}!

-

You can now login via username: {{.User.Name}}.

-

{{.AppUrl}}user/login

-

© 2015 Gogs: Go Git Service

+

Hi {{.Username}}, this is your registration confirmation email for {{AppName}}!

+

You can now login via username: {{.Username}}.

+

{{AppUrl}}user/login

+

© 2016 {{AppName}}

diff --git a/templates/mail/auth/reset_passwd.tmpl b/templates/mail/auth/reset_passwd.tmpl index eb29477828..aae0558052 100644 --- a/templates/mail/auth/reset_passwd.tmpl +++ b/templates/mail/auth/reset_passwd.tmpl @@ -2,14 +2,14 @@ - {{.User.Name}}, you have requested to reset your password + {{.Username}}, you have requested to reset your password -

Hi {{.User.Name}},

-

Please click the following link to verify your e-mail address within {{.ActiveCodeLives}} hours:

-

{{.AppUrl}}user/reset_password?code={{.Code}}

+

Hi {{.Username}},

+

Please click the following link to verify your email address within {{.ResetPwdCodeLives}} hours:

+

{{AppUrl}}user/reset_password?code={{.Code}}

Not working? Try copying and pasting it to your browser.

-

© 2015 Gogs: Go Git Service

+

© 2016 {{AppName}}

diff --git a/templates/mail/notify/mention.tmpl b/templates/mail/issue/comment.tmpl similarity index 58% rename from templates/mail/notify/mention.tmpl rename to templates/mail/issue/comment.tmpl index 048a0c9c1c..5dabb51950 100644 --- a/templates/mail/notify/mention.tmpl +++ b/templates/mail/issue/comment.tmpl @@ -6,12 +6,11 @@ -

@{{.ActUserName}} mentioned you:

-

{{.Content | Str2html}}

+

{{.Body | Str2html}}

---
- View it on Gogs. + View it on Gogs.

diff --git a/templates/mail/issue/mention.tmpl b/templates/mail/issue/mention.tmpl new file mode 100644 index 0000000000..53ff367e24 --- /dev/null +++ b/templates/mail/issue/mention.tmpl @@ -0,0 +1,17 @@ + + + + + {{.Subject}} + + + +

@{{.Doer.Name}} mentioned you:

+

{{.Body | Str2html}}

+

+ --- +
+ View it on Gogs. +

+ + \ No newline at end of file diff --git a/templates/mail/notify/collaborator.tmpl b/templates/mail/notify/collaborator.tmpl index a4748093ad..63d5637183 100644 --- a/templates/mail/notify/collaborator.tmpl +++ b/templates/mail/notify/collaborator.tmpl @@ -6,11 +6,11 @@ -

You are now a collaborator of this repository.

+

You have been added as a collaborator of repository: {{.RepoName}}

---
- View it on Gogs: {{.RepoLink}} + View it on Gogs.