// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package email import ( "bytes" "encoding/json" "fmt" "html/template" "io" "net/http" "net/url" "strings" "time" "github.com/pkg/errors" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/platform/shared/mail" "github.com/mattermost/mattermost/server/v8/platform/shared/templates" "github.com/microcosm-cc/bluemonday" ) // Returns category if enabled is true (default false) // If "" is returned when enabled is false, the category headers aren't attached to the email func getSendGridCategory(category string, enabled bool) string { if enabled { return category } return "" } func (es *Service) SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.username_change_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "TeamDisplayName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.username_change_body.title") data.Props["Info"] = T("api.templates.username_change_body.info", map[string]any{"TeamDisplayName": es.config().TeamSettings.SiteName, "NewUsername": newUsername}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("email_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "ChangeUsernameEmail"); err != nil { return err } return nil } func (es *Service) SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/do_verify_email?token=%s&email=%s", siteURL, token, url.QueryEscape(newUserEmail)) subject := T("api.templates.email_change_verify_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "TeamDisplayName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.email_change_verify_body.title") data.Props["Info"] = T("api.templates.email_change_verify_body.info", map[string]any{"TeamDisplayName": es.config().TeamSettings.SiteName}) data.Props["VerifyUrl"] = link data.Props["VerifyButton"] = T("api.templates.email_change_verify_body.button") data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["EmailInfo1"] = T("api.templates.email_us_anytime_at") data.Props["SupportEmail"] = "feedback@mattermost.com" data.Props["FooterV2"] = T("api.templates.email_footer_v2", map[string]any{"CurrentYear": time.Now().Year()}) body, err := es.templatesContainer.RenderToString("email_change_verify_body", data) if err != nil { return err } if err := es.sendMail(newUserEmail, subject, body, "EmailChangeVerifyEmail"); err != nil { return err } return nil } func (es *Service) SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.email_change_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "TeamDisplayName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.email_change_body.title") data.Props["Info"] = T("api.templates.email_change_body.info", map[string]any{"TeamDisplayName": es.config().TeamSettings.SiteName, "NewEmail": newEmail}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("email_change_body", data) if err != nil { return err } if err := es.sendMail(oldEmail, subject, body, "EmailChangeEmail"); err != nil { return err } return nil } func (es *Service) SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/do_verify_email?token=%s&email=%s", siteURL, token, url.QueryEscape(userEmail)) if redirect != "" { link += fmt.Sprintf("&redirect_to=%s", redirect) } serverURL := condenseSiteURL(siteURL) subject := T("api.templates.verify_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.verify_body.title") data.Props["SubTitle1"] = T("api.templates.verify_body.subTitle1") data.Props["ServerURL"] = T("api.templates.verify_body.serverURL", map[string]any{"ServerURL": serverURL}) data.Props["SubTitle2"] = T("api.templates.verify_body.subTitle2") data.Props["ButtonURL"] = link data.Props["Button"] = T("api.templates.verify_body.button") data.Props["Info"] = T("api.templates.verify_body.info") data.Props["Info1"] = T("api.templates.verify_body.info1") data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") body, err := es.templatesContainer.RenderToString("verify_body", data) if err != nil { return err } if err := es.sendMail(userEmail, subject, body, "VerifyEmail"); err != nil { return err } return nil } func (es *Service) SendSignInChangeEmail(email, method, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.signin_change_email.subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.signin_change_email.body.title") data.Props["Info"] = T("api.templates.signin_change_email.body.info", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "Method": method}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("signin_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "SignInChangeEmail"); err != nil { return err } return nil } func (es *Service) SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error { if disableWelcomeEmail { return nil } if !*es.config().EmailSettings.SendEmailNotifications && !*es.config().EmailSettings.RequireEmailVerification { return errors.New("send email notifications and require email verification is disabled in the system console") } T := i18n.GetUserTranslations(locale) serverURL := condenseSiteURL(siteURL) subject := T("api.templates.welcome_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "ServerURL": serverURL}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.welcome_body.title") data.Props["SubTitle1"] = T("api.templates.welcome_body.subTitle1") data.Props["ServerURL"] = T("api.templates.welcome_body.serverURL", map[string]any{"ServerURL": serverURL}) data.Props["SubTitle2"] = T("api.templates.welcome_body.subTitle2") data.Props["Button"] = T("api.templates.welcome_body.button") data.Props["Info"] = T("api.templates.welcome_body.info") data.Props["Info1"] = T("api.templates.welcome_body.info1") data.Props["SiteURL"] = siteURL if *es.config().NativeAppSettings.AppDownloadLink != "" { data.Props["AppDownloadTitle"] = T("api.templates.welcome_body.app_download_title") data.Props["AppDownloadInfo"] = T("api.templates.welcome_body.app_download_info") data.Props["AppDownloadButton"] = T("api.templates.welcome_body.app_download_button") data.Props["AppDownloadLink"] = *es.config().NativeAppSettings.AppDownloadLink } if !verified && *es.config().EmailSettings.RequireEmailVerification { token, err := es.CreateVerifyEmailToken(userID, email) if err != nil { return err } link := fmt.Sprintf("%s/do_verify_email?token=%s&email=%s", siteURL, token.Token, url.QueryEscape(email)) if redirect != "" { link += fmt.Sprintf("&redirect_to=%s", redirect) } data.Props["ButtonURL"] = link } body, err := es.templatesContainer.RenderToString("welcome_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "WelcomeEmail"); err != nil { return err } return nil } // SendCloudWelcomeEmail sends the cloud version of the welcome email func (es *Service) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_welcome_email.subject") data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_welcome_email.title") data.Props["SubTitle"] = T("api.templates.cloud_welcome_email.subtitle") data.Props["SubTitleInfo"] = T("api.templates.cloud_welcome_email.subtitle_info") data.Props["Info"] = T("api.templates.cloud_welcome_email.info") data.Props["Info2"] = T("api.templates.cloud_welcome_email.info2") data.Props["WorkSpacePath"] = siteURL data.Props["DNS"] = dns data.Props["InviteInfo"] = T("api.templates.cloud_welcome_email.invite_info") data.Props["InviteSubInfo"] = T("api.templates.cloud_welcome_email.invite_sub_info", map[string]any{"WorkSpace": workSpaceName}) data.Props["InviteSubInfoLink"] = fmt.Sprintf("%s/signup_user_complete/?id=%s", siteURL, teamInviteID) data.Props["AddAppsInfo"] = T("api.templates.cloud_welcome_email.add_apps_info") data.Props["AddAppsSubInfo"] = T("api.templates.cloud_welcome_email.add_apps_sub_info") data.Props["AppMarketPlace"] = T("api.templates.cloud_welcome_email.app_market_place") data.Props["AppMarketPlaceLink"] = "https://integrations.mattermost.com/" data.Props["DownloadMMInfo"] = T("api.templates.cloud_welcome_email.download_mm_info") data.Props["SignInSubInfo"] = T("api.templates.cloud_welcome_email.signin_sub_info") data.Props["MMApps"] = T("api.templates.cloud_welcome_email.mm_apps") data.Props["SignInSubInfo2"] = T("api.templates.cloud_welcome_email.signin_sub_info2") if es.config().NativeAppSettings.AppDownloadLink != nil && *es.config().NativeAppSettings.AppDownloadLink != "" { data.Props["DownloadMMAppsLink"] = es.config().NativeAppSettings.AppDownloadLink } else { data.Props["DownloadMMAppsLink"] = "https://mattermost.com/pl/download-apps" } data.Props["Button"] = T("api.templates.cloud_welcome_email.button") data.Props["GettingStartedQuestions"] = T("api.templates.cloud_welcome_email.start_questions") body, err := es.templatesContainer.RenderToString("cloud_welcome_email", data) if err != nil { return err } if err := es.sendEmailWithCustomReplyTo(userEmail, subject, body, *es.config().SupportSettings.SupportEmail, "CloudWelcomeEmail"); err != nil { return err } return nil } func (es *Service) SendPasswordChangeEmail(email, method, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.password_change_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "TeamDisplayName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.password_change_body.title") data.Props["Info"] = T("api.templates.password_change_body.info", map[string]any{"TeamDisplayName": es.config().TeamSettings.SiteName, "TeamURL": siteURL, "Method": method}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("password_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "PasswordChangeEmail"); err != nil { return err } return nil } func (es *Service) SendUserAccessTokenAddedEmail(email, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.user_access_token_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.user_access_token_body.title") data.Props["Info"] = T("api.templates.user_access_token_body.info", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "SiteURL": siteURL}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("password_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "UserAccessTokenAddedEmail"); err != nil { return err } return nil } func (es *Service) SendUserAccessTokenRotatedEmail(email, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.user_access_token_rotated_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.user_access_token_rotated_body.title") data.Props["Info"] = T("api.templates.user_access_token_rotated_body.info", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "SiteURL": siteURL}) data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("password_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "UserAccessTokenRotatedEmail"); err != nil { return err } return nil } func (es *Service) SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/reset_password_complete?token=%s", siteURL, url.QueryEscape(token.Token)) subject := T("api.templates.reset_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.reset_body.title") data.Props["SubTitle"] = T("api.templates.reset_body.subTitle") data.Props["Info"] = T("api.templates.reset_body.info") data.Props["ButtonURL"] = link data.Props["Button"] = T("api.templates.reset_body.button") data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") body, err := es.templatesContainer.RenderToString("reset_body", data) if err != nil { return false, err } if err := es.sendMail(email, subject, body, "PasswordResetEmail"); err != nil { return false, err } return true, nil } func (es *Service) SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.mfa_change_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL if activated { data.Props["Info"] = T("api.templates.mfa_activated_body.info", map[string]any{"SiteURL": siteURL}) data.Props["Title"] = T("api.templates.mfa_activated_body.title") } else { data.Props["Info"] = T("api.templates.mfa_deactivated_body.info", map[string]any{"SiteURL": siteURL}) data.Props["Title"] = T("api.templates.mfa_deactivated_body.title") } data.Props["Warning"] = T("api.templates.email_warning") body, err := es.templatesContainer.RenderToString("mfa_change_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "MfaChangeEmail"); err != nil { return err } return nil } func buildInviteTokenData(team *model.Team, invite string, profile *model.MemberInviteProfile) (map[string]string, map[string]string) { tokenExtra := map[string]string{"teamId": team.Id, "email": invite} tokenProps := map[string]string{ "email": invite, "display_name": team.DisplayName, "name": team.Name, } // The token extra is authoritative at signup; the link props are prefill only. if profile != nil { for key, value := range map[string]string{ "username": profile.Username, "first_name": profile.FirstName, "last_name": profile.LastName, } { tokenExtra[key] = value tokenProps[key] = value } } return tokenExtra, tokenProps } func (es *Service) SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error { if es.perHourEmailRateLimiter == nil { return NoRateLimiterError } rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), inviteData.SenderUserID, len(inviteData.Invites)) if err != nil { return SetupRateLimiterError } if rateLimited { mlog.Error("rate limit exceeded", mlog.Duration("retry_after", result.RetryAfter), mlog.Duration("reset_after", result.ResetAfter), mlog.String("user_id", inviteData.SenderUserID), mlog.String("team_id", inviteData.Team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds()))) return RateLimitExceededError } for _, invite := range inviteData.Invites { if invite != "" { subject := i18n.T("api.templates.invite_subject", map[string]any{"SenderName": inviteData.SenderName, "TeamDisplayName": inviteData.Team.DisplayName, "SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData("") data.Props["SiteURL"] = inviteData.SiteURL data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle") data.Props["Button"] = i18n.T("api.templates.invite_body.button") data.Props["SenderName"] = inviteData.SenderName data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title") data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info") data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more") tokenExtra, tokenProps := buildInviteTokenData(inviteData.Team, invite, inviteData.Profiles[invite]) token := model.NewToken( TokenTypeTeamInvitation, model.MapToJSON(tokenExtra), ) title := i18n.T("api.templates.invite_body.title", map[string]any{"SenderName": inviteData.SenderName, "TeamDisplayName": inviteData.Team.DisplayName}) if inviteData.ReminderData != nil { reminder := i18n.T("api.templates.invite_body.title.reminder") title = fmt.Sprintf("%s: %s", reminder, title) tokenProps["reminder_interval"] = inviteData.ReminderData.Interval } data.Props["Title"] = title tokenData := model.MapToJSON(tokenProps) if err := es.store.Token().Save(token); err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) continue } queryString := url.Values{} queryString.Add("d", tokenData) queryString.Add("t", token.Token) queryString.Add("md", "email") queryString.Add("sbr", es.GetTrackFlowStartedByRole(inviteData.IsFirstAdmin, inviteData.IsSystemAdmin)) data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?%s", inviteData.SiteURL, queryString.Encode()) body, err := es.templatesContainer.RenderToString("invite_body", data) if err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) } if err := es.sendMail(invite, subject, body, "InviteEmail"); err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) if inviteData.ErrorWhenNotSent { return SendMailError } } } } return nil } const magicLinkURL = "%s/landing#/login/one_time_link?t=%s" func (es *Service) 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 { if es.perHourEmailRateLimiter == nil { return NoRateLimiterError } rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), senderUserId, len(invites)) if err != nil { return SetupRateLimiterError } if rateLimited { mlog.Error("rate limit exceeded", mlog.Duration("retry_after", result.RetryAfter), mlog.Duration("reset_after", result.ResetAfter), mlog.String("user_id", senderUserId), mlog.String("team_id", team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds()))) return RateLimitExceededError } for _, invite := range invites { if invite != "" { subject := i18n.T("api.templates.invite_guest_subject", map[string]any{"SenderName": senderName, "TeamDisplayName": team.DisplayName, "SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData("") data.Props["SiteURL"] = siteURL data.Props["Title"] = i18n.T("api.templates.invite_body.title", map[string]any{"SenderName": senderName, "TeamDisplayName": team.DisplayName}) data.Props["SubTitle"] = i18n.T("api.templates.invite_body_guest.subTitle") data.Props["Button"] = i18n.T("api.templates.invite_body.button") data.Props["SenderName"] = senderName if message != "" { message = bluemonday.NewPolicy().Sanitize(message) } data.Props["Message"] = message data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title") data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info") data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more") channelIDs := []string{} for _, channel := range channels { channelIDs = append(channelIDs, channel.Id) } tokenType := TokenTypeGuestInvitation if isGuestMagicLink { tokenType = TokenTypeGuestMagicLinkInvitation } token := model.NewToken( tokenType, model.MapToJSON(map[string]string{ "teamId": team.Id, "channels": strings.Join(channelIDs, " "), "email": invite, "guest": "true", "senderId": senderUserId, }), ) tokenProps := make(map[string]string) tokenProps["email"] = invite tokenProps["display_name"] = team.DisplayName tokenProps["name"] = team.Name tokenData := model.MapToJSON(tokenProps) if err := es.store.Token().Save(token); err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) continue } if isGuestMagicLink { // Guest magic link uses SSO-style authentication - clicking the link sends them to the landing page and logs them in directly data.Props["ButtonURL"] = fmt.Sprintf(magicLinkURL, siteURL, url.QueryEscape(token.Token)) } else { data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&t=%s&sbr=%s", siteURL, url.QueryEscape(tokenData), url.QueryEscape(token.Token), es.GetTrackFlowStartedByRole(isFirstAdmin, isSystemAdmin)) } if !*es.config().EmailSettings.SendEmailNotifications { mlog.Info("sending invitation ", mlog.String("to", invite), mlog.String("link", data.Props["ButtonURL"].(string))) } senderPhoto := "" embeddedFiles := make(map[string]io.Reader) if message != "" { if senderProfileImage != nil { senderPhoto = "user-avatar.png" embeddedFiles = map[string]io.Reader{ senderPhoto: bytes.NewReader(senderProfileImage), } } } pData := postData{ SenderName: senderName, Message: template.HTML(message), SenderPhoto: senderPhoto, } data.Props["Posts"] = []postData{pData} body, err := es.templatesContainer.RenderToString("invite_body", data) if err != nil { mlog.Error("Failed to send invite email successfully", mlog.Err(err)) } if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, embeddedFiles, "", "", "", "InviteEmail"); nErr != nil { mlog.Error("Failed to send invite email successfully", mlog.Err(nErr)) if errorWhenNotSent { return SendMailError } } } } return nil } // SendMagicLinkEmailSelfService sends a passwordless login link to an existing guest user // This is for self-service login requests (no sender, no team/channel context). // For admin-initiated guest magic link invitations with team/channel assignment, use SendGuestInviteEmails with isGuestMagicLink=true. func (es *Service) SendMagicLinkEmailSelfService( rctx request.CTX, invite string, siteURL string, ) error { if es.perHourEmailRateLimiter == nil { return NoRateLimiterError } // Rate limit by email address for self-service requests rateLimited, result, err := es.perMinuteEmailRateLimiter.RateLimitCtx(rctx.Context(), invite, 1) if err != nil { return SetupRateLimiterError } if rateLimited { mlog.Error("rate limit exceeded", mlog.Duration("retry_after", result.RetryAfter), mlog.Duration("reset_after", result.ResetAfter), mlog.String("email", invite), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds()))) return RateLimitExceededError } if invite == "" { return nil } subject := i18n.T("api.templates.guest_magic_link_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName}) data := es.NewEmailTemplateData("") data.Props["SiteURL"] = siteURL data.Props["Title"] = i18n.T("api.templates.guest_magic_link_body.title") data.Props["SubTitle"] = i18n.T("api.templates.guest_magic_link_body.subtitle") data.Props["Button"] = i18n.T("api.templates.invite_body.button") data.Props["InviteFooterTitle"] = i18n.T("api.templates.guest_magic_link_body.footer.title") data.Props["InviteFooterInfo"] = i18n.T("api.templates.guest_magic_link_body.footer.info") // Login-only token - no team or channel info needed token, err := es.store.Token().GetTokenByTypeAndEmail(TokenTypeGuestMagicLink, invite) if err != nil || token.IsExpired() { // No existing token found, create a new one token = model.NewToken( TokenTypeGuestMagicLink, model.MapToJSON(map[string]string{ "email": invite, }), ) } if saveErr := es.store.Token().Save(token); saveErr != nil { mlog.Error("Failed to save guest magic link token", mlog.Err(saveErr)) return fmt.Errorf("%w: %v", SaveTokenError, saveErr) } // Guest magic link uses SSO-style authentication - clicking the link sends them to the landing page and logs them in directly data.Props["ButtonURL"] = fmt.Sprintf(magicLinkURL, siteURL, url.QueryEscape(token.Token)) if !*es.config().EmailSettings.SendEmailNotifications { mlog.Info("sending guest magic link", mlog.String("to", invite)) } body, err := es.templatesContainer.RenderToString("invite_body", data) if err != nil { mlog.Error("Failed to send guest magic link email successfully", mlog.Err(err)) } if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, nil, "", "", "", "GuestMagicLinkEmail"); nErr != nil { mlog.Error("Failed to send guest magic link email successfully", mlog.Err(nErr)) } return nil } func (es *Service) SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error) { if es.perHourEmailRateLimiter == nil { return nil, NoRateLimiterError } rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), inviteData.SenderUserID, len(inviteData.Invites)) if err != nil { return nil, SetupRateLimiterError } if rateLimited { mlog.Error("rate limit exceeded", mlog.Duration("retry_after", result.RetryAfter), mlog.Duration("reset_after", result.ResetAfter), mlog.String("user_id", inviteData.SenderUserID), mlog.String("team_id", inviteData.Team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds()))) return nil, RateLimitExceededError } channelsLen := len(inviteData.Channels) subject := i18n.T("api.templates.invite_team_and_channels_subject", map[string]any{ "SenderName": inviteData.SenderName, "TeamDisplayName": inviteData.Team.DisplayName, "ChannelsLen": channelsLen, "SiteName": es.config().TeamSettings.SiteName}) title := i18n.T("api.templates.invite_team_and_channels_body.title", map[string]any{ "SenderName": inviteData.SenderName, "ChannelsLen": channelsLen, "TeamDisplayName": inviteData.Team.DisplayName}) if channelsLen == 1 { channelName := inviteData.Channels[0].DisplayName subject = i18n.T("api.templates.invite_team_and_channel_subject", map[string]any{"SenderName": inviteData.SenderName, "TeamDisplayName": inviteData.Team.DisplayName, "ChannelName": channelName, "SiteName": es.config().TeamSettings.SiteName}, ) title = i18n.T("api.templates.invite_team_and_channel_body.title", map[string]any{ "SenderName": inviteData.SenderName, "ChannelName": channelName, "TeamDisplayName": inviteData.Team.DisplayName, }) } var invitesWithErrors []*model.EmailInviteWithError for _, invite := range inviteData.Invites { if invite == "" { continue } channelIDs := []string{} for _, channel := range inviteData.Channels { channelIDs = append(channelIDs, channel.Id) } data := es.NewEmailTemplateData("") data.Props["SiteURL"] = inviteData.SiteURL data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle") data.Props["Button"] = i18n.T("api.templates.invite_body.button") data.Props["SenderName"] = inviteData.SenderName data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title") data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info") data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more") if inviteData.Message != "" { inviteData.Message = bluemonday.NewPolicy().Sanitize(inviteData.Message) } data.Props["Message"] = inviteData.Message tokenExtra, tokenProps := buildInviteTokenData(inviteData.Team, invite, inviteData.Profiles[invite]) tokenExtra["channels"] = strings.Join(channelIDs, " ") tokenExtra["senderId"] = inviteData.SenderUserID token := model.NewToken( TokenTypeTeamInvitation, model.MapToJSON(tokenExtra), ) if inviteData.ReminderData != nil { reminder := i18n.T("api.templates.invite_body.title.reminder") title = fmt.Sprintf("%s: %s", reminder, title) tokenProps["reminder_interval"] = inviteData.ReminderData.Interval } data.Props["Title"] = title tokenData := model.MapToJSON(tokenProps) if err := es.store.Token().Save(token); err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) continue } data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&t=%s&sbr=%s", inviteData.SiteURL, url.QueryEscape(tokenData), url.QueryEscape(token.Token), es.GetTrackFlowStartedByRole(inviteData.IsFirstAdmin, inviteData.IsSystemAdmin)) senderPhoto := "" embeddedFiles := make(map[string]io.Reader) if inviteData.Message != "" { if inviteData.SenderProfileImage != nil { senderPhoto = "user-avatar.png" embeddedFiles = map[string]io.Reader{ senderPhoto: bytes.NewReader(inviteData.SenderProfileImage), } } } pData := postData{ SenderName: inviteData.SenderName, Message: template.HTML(inviteData.Message), SenderPhoto: senderPhoto, } data.Props["Posts"] = []postData{pData} body, err := es.templatesContainer.RenderToString("invite_body", data) if err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) } if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, embeddedFiles, "", "", "", "InviteEmailToTeamsAndChannels"); nErr != nil { mlog.Error("Failed to send invite email successfully", mlog.Err(nErr)) if inviteData.ErrorWhenNotSent { inviteWithError := &model.EmailInviteWithError{ Email: invite, Error: &model.AppError{Message: nErr.Error()}, } invitesWithErrors = append(invitesWithErrors, inviteWithError) } } } return invitesWithErrors, nil } func (es *Service) NewEmailTemplateData(locale string) templates.Data { var localT i18n.TranslateFunc if locale != "" { localT = i18n.GetUserTranslations(locale) } else { localT = i18n.T } organization := "" if *es.config().EmailSettings.FeedbackOrganization != "" { organization = localT("api.templates.email_organization") + *es.config().EmailSettings.FeedbackOrganization } return templates.Data{ Props: map[string]any{ "EmailInfo1": localT("api.templates.email_info1"), "EmailInfo2": localT("api.templates.email_info2"), "EmailInfo3": localT("api.templates.email_info3", map[string]any{"SiteName": es.config().TeamSettings.SiteName}), "SupportEmail": *es.config().SupportSettings.SupportEmail, "Footer": localT("api.templates.email_footer"), "FooterV2": localT("api.templates.email_footer_v2", map[string]any{"CurrentYear": time.Now().Year()}), "Organization": organization, }, HTML: map[string]template.HTML{}, } } func (es *Service) SendDeactivateAccountEmail(email string, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) serverURL := condenseSiteURL(siteURL) subject := T("api.templates.deactivate_subject", map[string]any{"SiteName": es.config().TeamSettings.SiteName, "ServerURL": serverURL}) data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.deactivate_body.title", map[string]any{"ServerURL": serverURL}) data.Props["Info"] = T("api.templates.deactivate_body.info", map[string]any{"SiteURL": siteURL}) data.Props["Warning"] = T("api.templates.deactivate_body.warning") body, err := es.templatesContainer.RenderToString("deactivate_body", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "DeactivateAccountEmail"); err != nil { // this needs to receive the header options return err } return nil } func (es *Service) SendNotificationMail(to, subject, htmlBody string) error { if !*es.config().EmailSettings.SendEmailNotifications { return nil } return es.sendMail(to, subject, htmlBody, "NotificationEmail") } func (es *Service) sendMail(to, subject, htmlBody, category string) error { return es.sendMailWithCC(to, subject, htmlBody, "", category) } func (es *Service) sendEmailWithCustomReplyTo(to, subject, htmlBody, replyToAddress, category string) error { license := es.license() mailConfig := es.mailServiceConfig(replyToAddress) category = getSendGridCategory(category, license.IsCloud()) return mail.SendMailUsingConfig(to, subject, htmlBody, mailConfig, license != nil && *license.Features.Compliance, "", "", "", "", category) } func (es *Service) sendMailWithCC(to, subject, htmlBody, ccMail, category string) error { license := es.license() mailConfig := es.mailServiceConfig("") category = getSendGridCategory(category, license.IsCloud()) return mail.SendMailUsingConfig(to, subject, htmlBody, mailConfig, license != nil && *license.Features.Compliance, "", "", "", ccMail, category) } func (es *Service) SendMailWithEmbeddedFilesAndCustomReplyTo(to, subject, htmlBody, replyToAddress string, embeddedFiles map[string]io.Reader, category string) error { license := es.license() mailConfig := es.mailServiceConfig(replyToAddress) category = getSendGridCategory(category, license.IsCloud()) return mail.SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, embeddedFiles, mailConfig, license != nil && *license.Features.Compliance, "", "", "", "", category) } func (es *Service) SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error { license := es.license() mailConfig := es.mailServiceConfig("") category = getSendGridCategory(category, license.IsCloud()) return mail.SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, embeddedFiles, mailConfig, license != nil && *license.Features.Compliance, messageID, inReplyTo, references, "", category) } func (es *Service) InvalidateVerifyEmailTokensForUser(userID string) *model.AppError { tokens, err := es.store.Token().GetAllTokensByType(TokenTypeVerifyEmail) if err != nil { return model.NewAppError("InvalidateVerifyEmailTokensForUser", "api.user.invalidate_verify_email_tokens.error", nil, "", http.StatusInternalServerError).Wrap(err) } var appErr *model.AppError for _, token := range tokens { tokenExtra := struct { UserId string Email string }{} if err := json.Unmarshal([]byte(token.Extra), &tokenExtra); err != nil { appErr = model.NewAppError("InvalidateVerifyEmailTokensForUser", "api.user.invalidate_verify_email_tokens_parse.error", nil, "", http.StatusInternalServerError).Wrap(err) continue } if tokenExtra.UserId != userID { continue } if err := es.store.Token().Delete(token.Token); err != nil { appErr = model.NewAppError("InvalidateVerifyEmailTokensForUser", "api.user.invalidate_verify_email_tokens_delete.error", nil, "", http.StatusInternalServerError).Wrap(err) } } return appErr } func (es *Service) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) { tokenExtra := struct { UserId string Email string }{ userID, newEmail, } jsonData, err := json.Marshal(tokenExtra) if err != nil { return nil, errors.Wrap(CreateEmailTokenError, err.Error()) } token := model.NewToken(TokenTypeVerifyEmail, string(jsonData)) if err := es.InvalidateVerifyEmailTokensForUser(userID); err != nil { return nil, err } if err = es.store.Token().Save(token); err != nil { return nil, err } return token, nil } func (es *Service) SendLicenseUpForRenewalEmail(email, locale string, daysToExpiration int) error { T := i18n.GetUserTranslations(locale) skuName := es.getLicenseSkuName() prefixedSkuName := es.getPrefixedLicenseSkuName() subject := T("api.templates.license_up_for_renewal_subject", map[string]any{"SkuName": prefixedSkuName}) siteName := es.getConfigSiteName() siteURL := *es.config().ServiceSettings.SiteURL data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.license_up_for_renewal_title") data.Props["Button"] = T("api.templates.license_up_for_renewal_contact_sales") data.Props["ButtonURL"] = "https://mattermost.com/contact-sales/" data.Props["NeedHelpTitle"] = T("api.templates.license_need_help.title") data.Props["SubTitleTwo"] = T("api.templates.license_up_for_renewal_subtitle_two") data.HTML["SubTitle"] = i18n.TranslateAsHTML(T, "api.templates.license_up_for_renewal_subtitle", map[string]any{"SkuName": skuName, "SiteURL": siteURL, "SiteName": siteName, "Days": daysToExpiration}) data.HTML["NeedHelpInfo"] = template.HTML(T("api.templates.license_need_help.info")) body, err := es.templatesContainer.RenderToString("license_notification", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "LicenseUpForRenewal"); err != nil { return err } return nil } func (es *Service) SendRemoveExpiredLicenseEmail(email, locale string) error { T := i18n.GetUserTranslations(locale) skuName := es.getLicenseSkuName() prefixedSkuName := es.getPrefixedLicenseSkuName() subject := T("api.templates.remove_expired_license.subject", map[string]any{"SkuName": prefixedSkuName}) siteName := es.getConfigSiteName() siteURL := *es.config().ServiceSettings.SiteURL data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.remove_expired_license.body.heading") data.Props["Button"] = T("api.templates.license_up_for_renewal_contact_sales") data.Props["ButtonURL"] = "https://mattermost.com/contact-sales/" data.Props["NeedHelpTitle"] = T("api.templates.license_need_help.title") data.Props["SubTitleTwo"] = T("api.templates.remove_expired_license.body.subtitle_two") data.HTML["SubTitle"] = i18n.TranslateAsHTML(T, "api.templates.remove_expired_license.body.subtitle", map[string]any{"SkuName": skuName, "SiteURL": siteURL, "SiteName": siteName}) data.HTML["NeedHelpInfo"] = template.HTML(T("api.templates.license_need_help.info")) body, err := es.templatesContainer.RenderToString("license_notification", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "RemoveExpiredLicense"); err != nil { return err } return nil } func (es *Service) SendIPFiltersChangedEmail(email string, initiatingUser *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.ip_filters_changed.subject") data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.ip_filters_changed.title") data.Props["SubTitle"] = T("api.templates.ip_filters_changed.subTitle", map[string]any{"InitiatingUsername": initiatingUser.Username, "SiteURL": siteURL}) data.Props["ButtonURL"] = siteURL + "/admin_console/site_config/ip_filtering" data.Props["Button"] = T("api.templates.ip_filters_changed.button") data.Props["TroubleAccessingTitle"] = T("api.templates.ip_filters_changed_footer.title") data.Props["SendAnEmailTo"] = T("api.templates.ip_filters_changed_footer.send_an_email_to", map[string]any{"InitiatingUserEmail": initiatingUser.Email}) data.Props["PortalURL"] = portalURL // If the email we're sending to was the one who initiated the change, we don't want to show their email address as a mailto if email != initiatingUser.Email { data.Props["ActorEmail"] = initiatingUser.Email } if isWorkspaceOwner { data.Props["LogInToCustomerPortal"] = T("api.templates.ip_filters_changed_footer.log_in_to_customer_portal") } data.Props["ContactSupport"] = T("api.templates.ip_filters_changed_footer.contact_support") data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail body, err := es.templatesContainer.RenderToString("ip_filters_changed", data) if err != nil { return err } if err := es.sendMail(email, subject, body, "PasswordResetEmail"); err != nil { return err } return nil }