From c3b2e44392e7f6c9a77a46664788c0bb9a6f33cb Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Wed, 19 Oct 2022 14:40:28 +0200
Subject: [PATCH] Add team member invite by email (#20307)

Allows to add (not registered) team members by email.

related #5353

Invite by mail:

![grafik](https://user-images.githubusercontent.com/1666336/178154779-adcc547f-c0b7-4a2a-a131-4e41a3d9d3ad.png)

Pending invitations:

![grafik](https://user-images.githubusercontent.com/1666336/178154882-9d739bb8-2b04-46c1-a025-c1f4be26af98.png)

Email:

![grafik](https://user-images.githubusercontent.com/1666336/178164716-f2f90893-7ba6-4a5e-a3db-42538a660258.png)

Join form:

![grafik](https://user-images.githubusercontent.com/1666336/178154840-aaab983a-d922-4414-b01a-9b1a19c5cef7.png)

Co-authored-by: Jack Hay <jjphay@gmail.com>
---
 models/migrations/migrations.go           |   2 +
 models/migrations/v228.go                 |  26 ++++
 models/org_team.go                        |  22 +--
 models/organization/org.go                |   3 +-
 models/organization/team.go               |   1 +
 models/organization/team_invite.go        | 162 +++++++++++++++++++++
 models/organization/team_invite_test.go   |  49 +++++++
 options/locale/locale_en-US.ini           |  11 ++
 routers/web/org/teams.go                  | 165 +++++++++++++++++++---
 routers/web/web.go                        |   5 +
 services/mailer/mail_release.go           |   2 +-
 services/mailer/mail_team_invite.go       |  62 ++++++++
 templates/mail/team_invite.tmpl           |  16 +++
 templates/org/team/invite.tmpl            |  23 +++
 templates/org/team/members.tmpl           |  17 ++-
 tests/integration/org_team_invite_test.go |  72 ++++++++++
 web_src/js/features/comp/SearchUserBox.js |  15 +-
 web_src/less/_organization.less           |   5 +
 18 files changed, 615 insertions(+), 43 deletions(-)
 create mode 100644 models/migrations/v228.go
 create mode 100644 models/organization/team_invite.go
 create mode 100644 models/organization/team_invite_test.go
 create mode 100644 services/mailer/mail_team_invite.go
 create mode 100644 templates/mail/team_invite.tmpl
 create mode 100644 templates/org/team/invite.tmpl
 create mode 100644 tests/integration/org_team_invite_test.go

diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index afe1445a23..46ef052829 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -417,6 +417,8 @@ var migrations = []Migration{
 	NewMigration("Conan and generic packages do not need to be semantically versioned", fixPackageSemverField),
 	// v227 -> v228
 	NewMigration("Create key/value table for system settings", createSystemSettingsTable),
+	// v228 -> v229
+	NewMigration("Add TeamInvite table", addTeamInviteTable),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v228.go b/models/migrations/v228.go
new file mode 100644
index 0000000000..62c81ef9d8
--- /dev/null
+++ b/models/migrations/v228.go
@@ -0,0 +1,26 @@
+// Copyright 2022 The Gitea 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 migrations
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func addTeamInviteTable(x *xorm.Engine) error {
+	type TeamInvite struct {
+		ID          int64              `xorm:"pk autoincr"`
+		Token       string             `xorm:"UNIQUE(token) INDEX NOT NULL DEFAULT ''"`
+		InviterID   int64              `xorm:"NOT NULL DEFAULT 0"`
+		OrgID       int64              `xorm:"INDEX NOT NULL DEFAULT 0"`
+		TeamID      int64              `xorm:"UNIQUE(team_mail) INDEX NOT NULL DEFAULT 0"`
+		Email       string             `xorm:"UNIQUE(team_mail) NOT NULL DEFAULT ''"`
+		CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+		UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+	}
+
+	return x.Sync2(new(TeamInvite))
+}
diff --git a/models/org_team.go b/models/org_team.go
index 61ddd2a047..6066e7f5c9 100644
--- a/models/org_team.go
+++ b/models/org_team.go
@@ -431,25 +431,15 @@ func DeleteTeam(t *organization.Team) error {
 		}
 	}
 
-	// Delete team-user.
-	if _, err := sess.
-		Where("org_id=?", t.OrgID).
-		Where("team_id=?", t.ID).
-		Delete(new(organization.TeamUser)); err != nil {
+	if err := db.DeleteBeans(ctx,
+		&organization.Team{ID: t.ID},
+		&organization.TeamUser{OrgID: t.OrgID, TeamID: t.ID},
+		&organization.TeamUnit{TeamID: t.ID},
+		&organization.TeamInvite{TeamID: t.ID},
+	); err != nil {
 		return err
 	}
 
-	// Delete team-unit.
-	if _, err := sess.
-		Where("team_id=?", t.ID).
-		Delete(new(organization.TeamUnit)); err != nil {
-		return err
-	}
-
-	// Delete team.
-	if _, err := sess.ID(t.ID).Delete(new(organization.Team)); err != nil {
-		return err
-	}
 	// Update organization number of teams.
 	if _, err := sess.Exec("UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil {
 		return err
diff --git a/models/organization/org.go b/models/organization/org.go
index fbbf6d04fa..58b58e6732 100644
--- a/models/organization/org.go
+++ b/models/organization/org.go
@@ -370,8 +370,9 @@ func DeleteOrganization(ctx context.Context, org *Organization) error {
 		&OrgUser{OrgID: org.ID},
 		&TeamUser{OrgID: org.ID},
 		&TeamUnit{OrgID: org.ID},
+		&TeamInvite{OrgID: org.ID},
 	); err != nil {
-		return fmt.Errorf("deleteBeans: %v", err)
+		return fmt.Errorf("DeleteBeans: %v", err)
 	}
 
 	if _, err := db.GetEngine(ctx).ID(org.ID).Delete(new(user_model.User)); err != nil {
diff --git a/models/organization/team.go b/models/organization/team.go
index 83e5bd6fe1..aa9b24b57f 100644
--- a/models/organization/team.go
+++ b/models/organization/team.go
@@ -94,6 +94,7 @@ func init() {
 	db.RegisterModel(new(TeamUser))
 	db.RegisterModel(new(TeamRepo))
 	db.RegisterModel(new(TeamUnit))
+	db.RegisterModel(new(TeamInvite))
 }
 
 // SearchTeamOptions holds the search options
diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go
new file mode 100644
index 0000000000..4504a2e9fe
--- /dev/null
+++ b/models/organization/team_invite.go
@@ -0,0 +1,162 @@
+// Copyright 2022 The Gitea 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 organization
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
+
+	"xorm.io/builder"
+)
+
+type ErrTeamInviteAlreadyExist struct {
+	TeamID int64
+	Email  string
+}
+
+func IsErrTeamInviteAlreadyExist(err error) bool {
+	_, ok := err.(ErrTeamInviteAlreadyExist)
+	return ok
+}
+
+func (err ErrTeamInviteAlreadyExist) Error() string {
+	return fmt.Sprintf("team invite already exists [team_id: %d, email: %s]", err.TeamID, err.Email)
+}
+
+func (err ErrTeamInviteAlreadyExist) Unwrap() error {
+	return util.ErrAlreadyExist
+}
+
+type ErrTeamInviteNotFound struct {
+	Token string
+}
+
+func IsErrTeamInviteNotFound(err error) bool {
+	_, ok := err.(ErrTeamInviteNotFound)
+	return ok
+}
+
+func (err ErrTeamInviteNotFound) Error() string {
+	return fmt.Sprintf("team invite was not found [token: %s]", err.Token)
+}
+
+func (err ErrTeamInviteNotFound) Unwrap() error {
+	return util.ErrNotExist
+}
+
+// ErrUserEmailAlreadyAdded represents a "user by email already added to team" error.
+type ErrUserEmailAlreadyAdded struct {
+	Email string
+}
+
+// IsErrUserEmailAlreadyAdded checks if an error is a ErrUserEmailAlreadyAdded.
+func IsErrUserEmailAlreadyAdded(err error) bool {
+	_, ok := err.(ErrUserEmailAlreadyAdded)
+	return ok
+}
+
+func (err ErrUserEmailAlreadyAdded) Error() string {
+	return fmt.Sprintf("user with email already added [email: %s]", err.Email)
+}
+
+func (err ErrUserEmailAlreadyAdded) Unwrap() error {
+	return util.ErrAlreadyExist
+}
+
+// TeamInvite represents an invite to a team
+type TeamInvite struct {
+	ID          int64              `xorm:"pk autoincr"`
+	Token       string             `xorm:"UNIQUE(token) INDEX NOT NULL DEFAULT ''"`
+	InviterID   int64              `xorm:"NOT NULL DEFAULT 0"`
+	OrgID       int64              `xorm:"INDEX NOT NULL DEFAULT 0"`
+	TeamID      int64              `xorm:"UNIQUE(team_mail) INDEX NOT NULL DEFAULT 0"`
+	Email       string             `xorm:"UNIQUE(team_mail) NOT NULL DEFAULT ''"`
+	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, email string) (*TeamInvite, error) {
+	has, err := db.GetEngine(ctx).Exist(&TeamInvite{
+		TeamID: team.ID,
+		Email:  email,
+	})
+	if err != nil {
+		return nil, err
+	}
+	if has {
+		return nil, ErrTeamInviteAlreadyExist{
+			TeamID: team.ID,
+			Email:  email,
+		}
+	}
+
+	// check if the user is already a team member by email
+	exist, err := db.GetEngine(ctx).
+		Where(builder.Eq{
+			"team_user.org_id":  team.OrgID,
+			"team_user.team_id": team.ID,
+			"`user`.email":      email,
+		}).
+		Join("INNER", "`user`", "`user`.id = team_user.uid").
+		Table("team_user").
+		Exist()
+	if err != nil {
+		return nil, err
+	}
+
+	if exist {
+		return nil, ErrUserEmailAlreadyAdded{
+			Email: email,
+		}
+	}
+
+	token, err := util.CryptoRandomString(25)
+	if err != nil {
+		return nil, err
+	}
+
+	invite := &TeamInvite{
+		Token:     token,
+		InviterID: doer.ID,
+		OrgID:     team.OrgID,
+		TeamID:    team.ID,
+		Email:     email,
+	}
+
+	return invite, db.Insert(ctx, invite)
+}
+
+func RemoveInviteByID(ctx context.Context, inviteID, teamID int64) error {
+	_, err := db.DeleteByBean(ctx, &TeamInvite{
+		ID:     inviteID,
+		TeamID: teamID,
+	})
+	return err
+}
+
+func GetInvitesByTeamID(ctx context.Context, teamID int64) ([]*TeamInvite, error) {
+	invites := make([]*TeamInvite, 0, 10)
+	return invites, db.GetEngine(ctx).
+		Where("team_id=?", teamID).
+		Find(&invites)
+}
+
+func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) {
+	invite := &TeamInvite{}
+
+	has, err := db.GetEngine(ctx).Where("token=?", token).Get(invite)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		return nil, ErrTeamInviteNotFound{Token: token}
+	}
+	return invite, nil
+}
diff --git a/models/organization/team_invite_test.go b/models/organization/team_invite_test.go
new file mode 100644
index 0000000000..e0596ec28d
--- /dev/null
+++ b/models/organization/team_invite_test.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The Gitea 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 organization_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/organization"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTeamInvite(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+
+	t.Run("MailExistsInTeam", func(t *testing.T) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+		// user 2 already added to team 2, should result in error
+		_, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, user2.Email)
+		assert.Error(t, err)
+	})
+
+	t.Run("CreateAndRemove", func(t *testing.T) {
+		user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+		invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com")
+		assert.NotNil(t, invite)
+		assert.NoError(t, err)
+
+		// Shouldn't allow duplicate invite
+		_, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "user3@example.com")
+		assert.Error(t, err)
+
+		// should remove invite
+		assert.NoError(t, organization.RemoveInviteByID(db.DefaultContext, invite.ID, invite.TeamID))
+
+		// invite should not exist
+		_, err = organization.GetInviteByToken(db.DefaultContext, invite.Token)
+		assert.Error(t, err)
+	})
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index e5da074f64..a35c6a668a 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -412,6 +412,11 @@ repo.transfer.body = To accept or reject it visit %s or just ignore it.
 repo.collaborator.added.subject = %s added you to %s
 repo.collaborator.added.text = You have been added as a collaborator of repository:
 
+team_invite.subject = %[1]s has invited you to join the %[2]s organization
+team_invite.text_1 = %[1]s has invited you to join team %[2]s in organization %[3]s.
+team_invite.text_2 = Please click the following link to join the team:
+team_invite.text_3 = Note: This invitation was intended for %[1]s. If you were not expecting this invitation, you can ignore this email.
+
 [modal]
 yes = Yes
 no = No
@@ -487,6 +492,7 @@ user_not_exist = The user does not exist.
 team_not_exist = The team does not exist.
 last_org_owner = You cannot remove the last user from the 'owners' team. There must be at least one owner for an organization.
 cannot_add_org_to_team = An organization cannot be added as a team member.
+duplicate_invite_to_team = The user was already invited as a team member.
 
 invalid_ssh_key = Can not verify your SSH key: %s
 invalid_gpg_key = Can not verify your GPG key: %s
@@ -2402,6 +2408,8 @@ teams.members = Team Members
 teams.update_settings = Update Settings
 teams.delete_team = Delete Team
 teams.add_team_member = Add Team Member
+teams.invite_team_member = Invite to %s
+teams.invite_team_member.list = Pending Invitations
 teams.delete_team_title = Delete Team
 teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue?
 teams.delete_team_success = The team has been deleted.
@@ -2426,6 +2434,9 @@ teams.all_repositories_helper = Team has access to all repositories. Selecting t
 teams.all_repositories_read_permission_desc = This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.
 teams.all_repositories_write_permission_desc = This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.
 teams.all_repositories_admin_permission_desc = This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.
+teams.invite.title = You've been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.
+teams.invite.by = Invited by %s
+teams.invite.description = Please click the button below to join the team.
 
 [admin]
 dashboard = Dashboard
diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go
index 13c88565c4..bcdbcbe079 100644
--- a/routers/web/org/teams.go
+++ b/routers/web/org/teams.go
@@ -14,7 +14,7 @@ import (
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/models/organization"
+	org_model "code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/models/perm"
 	repo_model "code.gitea.io/gitea/models/repo"
 	unit_model "code.gitea.io/gitea/models/unit"
@@ -23,9 +23,11 @@ import (
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/convert"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/utils"
 	"code.gitea.io/gitea/services/forms"
+	"code.gitea.io/gitea/services/mailer"
 	org_service "code.gitea.io/gitea/services/org"
 )
 
@@ -38,6 +40,8 @@ const (
 	tplTeamMembers base.TplName = "org/team/members"
 	// tplTeamRepositories template path for showing team repositories page
 	tplTeamRepositories base.TplName = "org/team/repositories"
+	// tplTeamInvite template path for team invites page
+	tplTeamInvite base.TplName = "org/team/invite"
 )
 
 // Teams render teams list page
@@ -59,12 +63,6 @@ func Teams(ctx *context.Context) {
 
 // TeamsAction response for join, leave, remove, add operations to team
 func TeamsAction(ctx *context.Context) {
-	uid := ctx.FormInt64("uid")
-	if uid == 0 {
-		ctx.Redirect(ctx.Org.OrgLink + "/teams")
-		return
-	}
-
 	page := ctx.FormString("page")
 	var err error
 	switch ctx.Params(":action") {
@@ -77,7 +75,7 @@ func TeamsAction(ctx *context.Context) {
 	case "leave":
 		err = models.RemoveTeamMember(ctx.Org.Team, ctx.Doer.ID)
 		if err != nil {
-			if organization.IsErrLastOrgOwner(err) {
+			if org_model.IsErrLastOrgOwner(err) {
 				ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
 			} else {
 				log.Error("Action(%s): %v", ctx.Params(":action"), err)
@@ -98,9 +96,16 @@ func TeamsAction(ctx *context.Context) {
 			ctx.Error(http.StatusNotFound)
 			return
 		}
+
+		uid := ctx.FormInt64("uid")
+		if uid == 0 {
+			ctx.Redirect(ctx.Org.OrgLink + "/teams")
+			return
+		}
+
 		err = models.RemoveTeamMember(ctx.Org.Team, uid)
 		if err != nil {
-			if organization.IsErrLastOrgOwner(err) {
+			if org_model.IsErrLastOrgOwner(err) {
 				ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
 			} else {
 				log.Error("Action(%s): %v", ctx.Params(":action"), err)
@@ -126,10 +131,27 @@ func TeamsAction(ctx *context.Context) {
 		u, err = user_model.GetUserByName(ctx, uname)
 		if err != nil {
 			if user_model.IsErrUserNotExist(err) {
-				ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
+				if setting.MailService != nil && user_model.ValidateEmail(uname) == nil {
+					invite, err := org_model.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname)
+					if err != nil {
+						if org_model.IsErrTeamInviteAlreadyExist(err) {
+							ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team"))
+						} else if org_model.IsErrUserEmailAlreadyAdded(err) {
+							ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users"))
+						} else {
+							ctx.ServerError("CreateTeamInvite", err)
+							return
+						}
+					} else if err := mailer.MailTeamInvite(ctx, ctx.Doer, ctx.Org.Team, invite); err != nil {
+						ctx.ServerError("MailTeamInvite", err)
+						return
+					}
+				} else {
+					ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
+				}
 				ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
 			} else {
-				ctx.ServerError(" GetUserByName", err)
+				ctx.ServerError("GetUserByName", err)
 			}
 			return
 		}
@@ -146,11 +168,30 @@ func TeamsAction(ctx *context.Context) {
 			err = models.AddTeamMember(ctx.Org.Team, u.ID)
 		}
 
+		page = "team"
+	case "remove_invite":
+		if !ctx.Org.IsOwner {
+			ctx.Error(http.StatusNotFound)
+			return
+		}
+
+		iid := ctx.FormInt64("iid")
+		if iid == 0 {
+			ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
+			return
+		}
+
+		if err := org_model.RemoveInviteByID(ctx, iid, ctx.Org.Team.ID); err != nil {
+			log.Error("Action(%s): %v", ctx.Params(":action"), err)
+			ctx.ServerError("RemoveInviteByID", err)
+			return
+		}
+
 		page = "team"
 	}
 
 	if err != nil {
-		if organization.IsErrLastOrgOwner(err) {
+		if org_model.IsErrLastOrgOwner(err) {
 			ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
 		} else {
 			log.Error("Action(%s): %v", ctx.Params(":action"), err)
@@ -224,7 +265,7 @@ func NewTeam(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Org.Organization.FullName
 	ctx.Data["PageIsOrgTeams"] = true
 	ctx.Data["PageIsOrgTeamsNew"] = true
-	ctx.Data["Team"] = &organization.Team{}
+	ctx.Data["Team"] = &org_model.Team{}
 	ctx.Data["Units"] = unit_model.Units
 	ctx.HTML(http.StatusOK, tplTeamNew)
 }
@@ -255,7 +296,7 @@ func NewTeamPost(ctx *context.Context) {
 		p = unit_model.MinUnitAccessMode(unitPerms)
 	}
 
-	t := &organization.Team{
+	t := &org_model.Team{
 		OrgID:                   ctx.Org.Organization.ID,
 		Name:                    form.TeamName,
 		Description:             form.Description,
@@ -265,9 +306,9 @@ func NewTeamPost(ctx *context.Context) {
 	}
 
 	if t.AccessMode < perm.AccessModeAdmin {
-		units := make([]*organization.TeamUnit, 0, len(unitPerms))
+		units := make([]*org_model.TeamUnit, 0, len(unitPerms))
 		for tp, perm := range unitPerms {
-			units = append(units, &organization.TeamUnit{
+			units = append(units, &org_model.TeamUnit{
 				OrgID:      ctx.Org.Organization.ID,
 				Type:       tp,
 				AccessMode: perm,
@@ -295,7 +336,7 @@ func NewTeamPost(ctx *context.Context) {
 	if err := models.NewTeam(t); err != nil {
 		ctx.Data["Err_TeamName"] = true
 		switch {
-		case organization.IsErrTeamAlreadyExist(err):
+		case org_model.IsErrTeamAlreadyExist(err):
 			ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form)
 		default:
 			ctx.ServerError("NewTeam", err)
@@ -316,6 +357,15 @@ func TeamMembers(ctx *context.Context) {
 		return
 	}
 	ctx.Data["Units"] = unit_model.Units
+
+	invites, err := org_model.GetInvitesByTeamID(ctx, ctx.Org.Team.ID)
+	if err != nil {
+		ctx.ServerError("GetInvitesByTeamID", err)
+		return
+	}
+	ctx.Data["Invites"] = invites
+	ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil
+
 	ctx.HTML(http.StatusOK, tplTeamMembers)
 }
 
@@ -339,7 +389,7 @@ func SearchTeam(ctx *context.Context) {
 		PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
 	}
 
-	opts := &organization.SearchTeamOptions{
+	opts := &org_model.SearchTeamOptions{
 		// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
 		Keyword:     ctx.FormTrim("q"),
 		OrgID:       ctx.Org.Organization.ID,
@@ -347,7 +397,7 @@ func SearchTeam(ctx *context.Context) {
 		ListOptions: listOptions,
 	}
 
-	teams, maxResults, err := organization.SearchTeam(opts)
+	teams, maxResults, err := org_model.SearchTeam(opts)
 	if err != nil {
 		log.Error("SearchTeam failed: %v", err)
 		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
@@ -424,16 +474,16 @@ func EditTeamPost(ctx *context.Context) {
 
 	t.Description = form.Description
 	if t.AccessMode < perm.AccessModeAdmin {
-		units := make([]organization.TeamUnit, 0, len(unitPerms))
+		units := make([]org_model.TeamUnit, 0, len(unitPerms))
 		for tp, perm := range unitPerms {
-			units = append(units, organization.TeamUnit{
+			units = append(units, org_model.TeamUnit{
 				OrgID:      t.OrgID,
 				TeamID:     t.ID,
 				Type:       tp,
 				AccessMode: perm,
 			})
 		}
-		if err := organization.UpdateTeamUnits(t, units); err != nil {
+		if err := org_model.UpdateTeamUnits(t, units); err != nil {
 			ctx.Error(http.StatusInternalServerError, "UpdateTeamUnits", err.Error())
 			return
 		}
@@ -452,7 +502,7 @@ func EditTeamPost(ctx *context.Context) {
 	if err := models.UpdateTeam(t, isAuthChanged, isIncludeAllChanged); err != nil {
 		ctx.Data["Err_TeamName"] = true
 		switch {
-		case organization.IsErrTeamAlreadyExist(err):
+		case org_model.IsErrTeamAlreadyExist(err):
 			ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form)
 		default:
 			ctx.ServerError("UpdateTeam", err)
@@ -474,3 +524,72 @@ func DeleteTeam(ctx *context.Context) {
 		"redirect": ctx.Org.OrgLink + "/teams",
 	})
 }
+
+// TeamInvite renders the team invite page
+func TeamInvite(ctx *context.Context) {
+	invite, org, team, inviter, err := getTeamInviteFromContext(ctx)
+	if err != nil {
+		if org_model.IsErrTeamInviteNotFound(err) {
+			ctx.NotFound("ErrTeamInviteNotFound", err)
+		} else {
+			ctx.ServerError("getTeamInviteFromContext", err)
+		}
+		return
+	}
+
+	ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name)
+	ctx.Data["Invite"] = invite
+	ctx.Data["Organization"] = org
+	ctx.Data["Team"] = team
+	ctx.Data["Inviter"] = inviter
+
+	ctx.HTML(http.StatusOK, tplTeamInvite)
+}
+
+// TeamInvitePost handles the team invitation
+func TeamInvitePost(ctx *context.Context) {
+	invite, org, team, _, err := getTeamInviteFromContext(ctx)
+	if err != nil {
+		if org_model.IsErrTeamInviteNotFound(err) {
+			ctx.NotFound("ErrTeamInviteNotFound", err)
+		} else {
+			ctx.ServerError("getTeamInviteFromContext", err)
+		}
+		return
+	}
+
+	if err := models.AddTeamMember(team, ctx.Doer.ID); err != nil {
+		ctx.ServerError("AddTeamMember", err)
+		return
+	}
+
+	if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil {
+		log.Error("RemoveInviteByID: %v", err)
+	}
+
+	ctx.Redirect(org.OrganisationLink() + "/teams/" + url.PathEscape(team.LowerName))
+}
+
+func getTeamInviteFromContext(ctx *context.Context) (*org_model.TeamInvite, *org_model.Organization, *org_model.Team, *user_model.User, error) {
+	invite, err := org_model.GetInviteByToken(ctx, ctx.Params("token"))
+	if err != nil {
+		return nil, nil, nil, nil, err
+	}
+
+	inviter, err := user_model.GetUserByIDCtx(ctx, invite.InviterID)
+	if err != nil {
+		return nil, nil, nil, nil, err
+	}
+
+	team, err := org_model.GetTeamByID(ctx, invite.TeamID)
+	if err != nil {
+		return nil, nil, nil, nil, err
+	}
+
+	org, err := user_model.GetUserByIDCtx(ctx, team.OrgID)
+	if err != nil {
+		return nil, nil, nil, nil, err
+	}
+
+	return invite, org_model.OrgFromUser(org), team, inviter, nil
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 8859ec5850..62503b3141 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -651,6 +651,11 @@ func RegisterRoutes(m *web.Route) {
 			m.Post("/create", bindIgnErr(forms.CreateOrgForm{}), org.CreatePost)
 		})
 
+		m.Group("/invite/{token}", func() {
+			m.Get("", org.TeamInvite)
+			m.Post("", org.TeamInvitePost)
+		})
+
 		m.Group("/{org}", func() {
 			m.Get("/dashboard", user.Dashboard)
 			m.Get("/dashboard/{team}", user.Dashboard)
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index 7c44f93929..6df3fbbf1d 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -23,7 +23,7 @@ const (
 	tplNewReleaseMail base.TplName = "release"
 )
 
-// MailNewRelease send new release notify to all all repo watchers.
+// MailNewRelease send new release notify to all repo watchers.
 func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
 	if setting.MailService == nil {
 		// No mail service configured
diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go
new file mode 100644
index 0000000000..c2b2a00e76
--- /dev/null
+++ b/services/mailer/mail_team_invite.go
@@ -0,0 +1,62 @@
+// Copyright 2022 The Gitea 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 (
+	"bytes"
+	"context"
+
+	org_model "code.gitea.io/gitea/models/organization"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/modules/translation"
+)
+
+const (
+	tplTeamInviteMail base.TplName = "team_invite"
+)
+
+// MailTeamInvite sends team invites
+func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error {
+	if setting.MailService == nil {
+		return nil
+	}
+
+	org, err := user_model.GetUserByIDCtx(ctx, team.OrgID)
+	if err != nil {
+		return err
+	}
+
+	locale := translation.NewLocale(inviter.Language)
+
+	subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName())
+	mailMeta := map[string]interface{}{
+		"Inviter":      inviter,
+		"Organization": org,
+		"Team":         team,
+		"Invite":       invite,
+		"Subject":      subject,
+		// helper
+		"locale":    locale,
+		"Str2html":  templates.Str2html,
+		"DotEscape": templates.DotEscape,
+	}
+
+	var mailBody bytes.Buffer
+	if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
+		log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
+		return err
+	}
+
+	msg := NewMessage([]string{invite.Email}, subject, mailBody.String())
+	msg.Info = subject
+
+	SendAsync(msg)
+
+	return nil
+}
diff --git a/templates/mail/team_invite.tmpl b/templates/mail/team_invite.tmpl
new file mode 100644
index 0000000000..163c950e94
--- /dev/null
+++ b/templates/mail/team_invite.tmpl
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+	<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"/>
+</head>
+{{$invite_url := printf "%sorg/invite/%s" AppUrl (QueryEscape .Invite.Token)}}
+<body>
+	<p>{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName) | Str2html}}</p>
+	<p>{{.locale.Tr "mail.team_invite.text_2"}}</p><p><a href="{{$invite_url}}">{{$invite_url}}</a></p>
+	<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
+	<p>{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}</p>
+
+	<p>© <a target="_blank" rel="noopener noreferrer" href="{{AppUrl}}">{{AppName}}</a></p>
+</body>
+</html>
diff --git a/templates/org/team/invite.tmpl b/templates/org/team/invite.tmpl
new file mode 100644
index 0000000000..a696d99498
--- /dev/null
+++ b/templates/org/team/invite.tmpl
@@ -0,0 +1,23 @@
+{{template "base/head" .}}
+<div class="page-content organization invite">
+	<div class="ui container">
+		{{template "base/alert" .}}
+		<div class="ui centered card">
+			<div class="image">
+				{{avatar .Organization 140}}
+			</div>
+			<div class="content">
+				<div class="header">{{.locale.Tr "org.teams.invite.title" .Team.Name .Organization.Name | Str2html}}</div>
+				<div class="meta">{{.locale.Tr "org.teams.invite.by" .Inviter.Name}}</div>
+				<div class="description">{{.locale.Tr "org.teams.invite.description"}}</div>
+			</div>
+			<div class="extra content">
+				<form class="ui form" action="" method="post">
+					{{.CsrfTokenHtml}}
+					<button class="fluid ui green button">{{.locale.Tr "org.teams.join"}}</button>
+				</form>
+			</div>
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/team/members.tmpl b/templates/org/team/members.tmpl
index ecb7830f18..1a58dc5339 100644
--- a/templates/org/team/members.tmpl
+++ b/templates/org/team/members.tmpl
@@ -13,7 +13,7 @@
 							{{.CsrfTokenHtml}}
 							<input type="hidden" name="uid" value="{{.SignedUser.ID}}">
 							<div class="inline field ui left">
-								<div id="search-user-box" class="ui search">
+								<div id="search-user-box" class="ui search"{{if .IsEmailInviteEnabled}} data-allow-email="true" data-allow-email-description="{{.locale.Tr "org.teams.invite_team_member" $.Team.Name}}"{{end}}>
 									<div class="ui input">
 										<input class="prompt" name="uname" placeholder="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
 									</div>
@@ -45,6 +45,21 @@
 						</div>
 					{{end}}
 				</div>
+				{{if and .Invites $.IsOrganizationOwner}}
+				<h4 class="ui top attached header">{{$.locale.Tr "org.teams.invite_team_member.list"}}</h4>
+				<div class="ui bottom attached table segment members">
+					{{range .Invites}}
+						<div class="item">
+							<form action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove_invite" method="post">
+								{{$.CsrfTokenHtml}}
+								<input type="hidden" name="iid" value="{{.ID}}" />
+								<button class="ui red button right">{{$.locale.Tr "org.members.remove"}}</button>
+							</form>
+							{{.Email}}
+						</div>
+					{{end}}
+				</div>
+				{{end}}
 			</div>
 		</div>
 	</div>
diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go
new file mode 100644
index 0000000000..470478589a
--- /dev/null
+++ b/tests/integration/org_team_invite_test.go
@@ -0,0 +1,72 @@
+// Copyright 2022 The Gitea 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 integration
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/organization"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOrgTeamEmailInvite(t *testing.T) {
+	if setting.MailService == nil {
+		t.Skip()
+		return
+	}
+
+	defer tests.PrepareTestEnv(t)()
+
+	org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+	team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+	isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+	assert.NoError(t, err)
+	assert.False(t, isMember)
+
+	session := loginUser(t, "user1")
+
+	url := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+	csrf := GetCSRF(t, session, url)
+	req := NewRequestWithValues(t, "POST", url+"/action/add", map[string]string{
+		"_csrf": csrf,
+		"uid":   "1",
+		"uname": user.Email,
+	})
+	resp := session.MakeRequest(t, req, http.StatusSeeOther)
+	req = NewRequest(t, "GET", test.RedirectURL(resp))
+	session.MakeRequest(t, req, http.StatusOK)
+
+	// get the invite token
+	invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+	assert.NoError(t, err)
+	assert.Len(t, invites, 1)
+
+	session = loginUser(t, user.Name)
+
+	// join the team
+	url = fmt.Sprintf("/org/invite/%s", invites[0].Token)
+	csrf = GetCSRF(t, session, url)
+	req = NewRequestWithValues(t, "POST", url, map[string]string{
+		"_csrf": csrf,
+	})
+	resp = session.MakeRequest(t, req, http.StatusSeeOther)
+	req = NewRequest(t, "GET", test.RedirectURL(resp))
+	session.MakeRequest(t, req, http.StatusOK)
+
+	isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+	assert.NoError(t, err)
+	assert.True(t, isMember)
+}
diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js
index 08f97595af..46ecb8ebf4 100644
--- a/web_src/js/features/comp/SearchUserBox.js
+++ b/web_src/js/features/comp/SearchUserBox.js
@@ -3,15 +3,20 @@ import {htmlEscape} from 'escape-goat';
 
 const {appSubUrl} = window.config;
 
+const looksLikeEmailAddressCheck = /^\S+@\S+$/;
+
 export function initCompSearchUserBox() {
   const $searchUserBox = $('#search-user-box');
+  const allowEmailInput = $searchUserBox.attr('data-allow-email') === 'true';
+  const allowEmailDescription = $searchUserBox.attr('data-allow-email-description');
   $searchUserBox.search({
     minCharacters: 2,
     apiSettings: {
       url: `${appSubUrl}/user/search?q={query}`,
       onResponse(response) {
         const items = [];
-        const searchQueryUppercase = $searchUserBox.find('input').val().toUpperCase();
+        const searchQuery = $searchUserBox.find('input').val();
+        const searchQueryUppercase = searchQuery.toUpperCase();
         $.each(response.data, (_i, item) => {
           let title = item.login;
           if (item.full_name && item.full_name.length > 0) {
@@ -28,6 +33,14 @@ export function initCompSearchUserBox() {
           }
         });
 
+        if (allowEmailInput && items.length === 0 && looksLikeEmailAddressCheck.test(searchQuery)) {
+          const resultItem = {
+            title: searchQuery,
+            description: allowEmailDescription
+          };
+          items.push(resultItem);
+        }
+
         return {results: items};
       }
     },
diff --git a/web_src/less/_organization.less b/web_src/less/_organization.less
index b80739671f..c52753e29b 100644
--- a/web_src/less/_organization.less
+++ b/web_src/less/_organization.less
@@ -119,6 +119,11 @@
         margin-top: -3px;
       }
     }
+
+    .ui.avatar {
+      width: 100%;
+      height: 100%;
+    }
   }
 
   &.members {