Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

222 lines
8.1 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"io"
"net/url"
"path"
"strings"
"github.com/pkg/errors"
"github.com/throttled/throttled/v2"
"github.com/throttled/throttled/v2/store/memstore"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/i18n"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/server/v8/channels/app/users"
"github.com/hanzoteam/server/server/v8/channels/store"
"github.com/hanzoteam/server/server/v8/platform/shared/templates"
)
const (
emailRateLimitingMemstoreSize = 65536
emailRateLimitingPerMinute = 60
emailRateLimitingPerHour = 20
emailRateLimitingMaxBurst = 20
TokenTypePasswordRecovery = "password_recovery"
TokenTypeVerifyEmail = "verify_email"
TokenTypeTeamInvitation = "team_invitation"
TokenTypeGuestInvitation = "guest_invitation"
TokenTypeCWSAccess = "cws_access_token"
TokenTypeGuestMagicLinkInvitation = "guest_magic_link_invitation"
TokenTypeGuestMagicLink = "guest_magic_link"
)
func condenseSiteURL(siteURL string) string {
parsedSiteURL, _ := url.Parse(siteURL)
if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" {
return parsedSiteURL.Host
}
return path.Join(parsedSiteURL.Host, parsedSiteURL.Path)
}
type Service struct {
config func() *model.Config
license func() *model.License
logger mlog.LoggerIFace
userService *users.UserService
store store.Store
templatesContainer *templates.Container
perMinuteEmailRateLimiter *throttled.GCRARateLimiterCtx
perHourEmailRateLimiter *throttled.GCRARateLimiterCtx
EmailBatching *EmailBatchingJob
}
type ServiceConfig struct {
ConfigFn func() *model.Config
LicenseFn func() *model.License
TemplatesContainer *templates.Container
UserService *users.UserService
Store store.Store
Logger mlog.LoggerIFace
}
func NewService(config ServiceConfig) (*Service, error) {
if err := config.validate(); err != nil {
return nil, err
}
service := &Service{
config: config.ConfigFn,
templatesContainer: config.TemplatesContainer,
license: config.LicenseFn,
logger: config.Logger,
store: config.Store,
userService: config.UserService,
}
if err := service.setUpRateLimiters(); err != nil {
return nil, err
}
service.InitEmailBatching()
return service, nil
}
func (es *Service) Stop() {
mlog.Info("Shutting down Email batching service...")
if es.EmailBatching != nil {
es.EmailBatching.Stop()
}
}
func (c *ServiceConfig) validate() error {
if c.ConfigFn == nil || c.Store == nil || c.LicenseFn == nil || c.TemplatesContainer == nil {
return errors.New("invalid service config")
}
return nil
}
func (es *Service) setUpRateLimiters() error {
store, err := memstore.NewCtx(emailRateLimitingMemstoreSize)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting memstore.")
}
perMinuteQuota := throttled.RateQuota{
MaxRate: throttled.PerMin(emailRateLimitingPerMinute),
MaxBurst: emailRateLimitingMaxBurst,
}
perHourQuota := throttled.RateQuota{
MaxRate: throttled.PerHour(emailRateLimitingPerHour),
MaxBurst: emailRateLimitingMaxBurst,
}
perMinuteRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perMinuteQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
perHourRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perHourQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
es.perMinuteEmailRateLimiter = perMinuteRateLimiter
es.perHourEmailRateLimiter = perHourRateLimiter
return nil
}
type InviteEmailData struct {
Team *model.Team
Channels []*model.Channel
SenderName string
SenderUserID string
SenderProfileImage []byte
Invites []string
Profiles map[string]*model.MemberInviteProfile
SiteURL string
ReminderData *model.TeamInviteReminderData
Message string
ErrorWhenNotSent bool
IsSystemAdmin bool
IsFirstAdmin bool
}
type ServiceInterface interface {
NewEmailTemplateData(locale string) templates.Data
SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error
SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error
SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error
SendSignInChangeEmail(email, method, locale, siteURL string) error
SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error
SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error
SendPasswordChangeEmail(email, method, locale, siteURL string) error
SendUserAccessTokenAddedEmail(email, locale, siteURL string) error
SendUserAccessTokenRotatedEmail(email, locale, siteURL string) error
SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error)
SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error
SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error
SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error
SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error
SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error)
SendDeactivateAccountEmail(email string, locale, siteURL string) error
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
SendLicenseUpForRenewalEmail(email, locale string, daysToExpiration int) error
SendRemoveExpiredLicenseEmail(email, locale string) error
AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string
GenerateHyperlinkForChannels(postMessage, teamName, teamURL string) (string, error)
InitEmailBatching()
SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error
CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error)
SendIPFiltersChangedEmail(email string, userWhoChangedFilter *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error
SetStore(st store.Store)
Stop()
}
// getLicenseSkuName returns the license tier descriptor (e.g. "Professional",
// "Entry", "E20"), stripping the "Hanzo Team " prefix if the license server
// included it. Falls back to "Hanzo Team" when the SKU name is absent so
// that body text like "Your {{.SkuName}} license..." still reads naturally.
func (es *Service) getLicenseSkuName() string {
if license := es.license(); license != nil && license.SkuName != "" {
return strings.TrimPrefix(license.SkuName, "Hanzo Team ")
}
return "Hanzo Team"
}
// getPrefixedLicenseSkuName returns the full product name including "Hanzo Team"
// (e.g. "Hanzo Team"), suitable for email subjects. Falls back
// to "Hanzo Team" when no license exists or the SKU name is empty.
func (es *Service) getPrefixedLicenseSkuName() string {
skuName := es.getLicenseSkuName()
if skuName == "Hanzo Team" {
return "Hanzo Team"
}
return "Hanzo Team " + skuName
}
func (es *Service) getConfigSiteName() string {
if siteName := *es.config().TeamSettings.SiteName; siteName != "" {
return siteName
}
return "Hanzo Team"
}
func (es *Service) Store() store.Store {
return es.store
}
func (es *Service) SetStore(st store.Store) {
es.store = st
}