diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 050d2cf9d6..d58b1a5b96 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -573,6 +573,7 @@ type AppIface interface { DoLocalRequest(c request.CTX, rawURL string, body []byte) (*http.Response, *model.AppError) DoLogin(c request.CTX, w http.ResponseWriter, r *http.Request, user *model.User, deviceID string, isMobile, isOAuthUser, isSaml bool) (*model.Session, *model.AppError) DoPostActionWithCookie(c request.CTX, postID, actionId, userID, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) + DoSubscriptionRenewalCheck() DoSystemConsoleRolesCreationMigration() DoUploadFile(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte) (*model.FileInfo, *model.AppError) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte) (*model.FileInfo, []byte, *model.AppError) diff --git a/server/channels/app/cloud.go b/server/channels/app/cloud.go index 57d7e79956..2f8909b8f0 100644 --- a/server/channels/app/cloud.go +++ b/server/channels/app/cloud.go @@ -8,11 +8,13 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/product" + "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/einterfaces" ) @@ -266,3 +268,98 @@ func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHi } return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount)) } + +func (a *App) DoSubscriptionRenewalCheck() { + if !a.License().IsCloud() || !a.Config().FeatureFlags.CloudAnnualRenewals { + return + } + + subscription, err := a.Cloud().GetSubscription("") + if err != nil { + a.Log().Error("Error getting subscription", mlog.Err(err)) + return + } + + if subscription == nil { + a.Log().Error("Subscription not found") + return + } + + sysVar, err := a.Srv().Store().System().GetByName(model.CloudRenewalEmail) + if err != nil { + // We only care about the error if it wasn't a not found error + if _, ok := err.(*store.ErrNotFound); !ok { + a.Log().Error(err.Error()) + } + } + + prevSentEmail := int64(0) + if sysVar != nil { + // We don't care about parse errors because it's possible the value is empty, and we've already defaulted to 0 + prevSentEmail, _ = strconv.ParseInt(sysVar.Value, 10, 64) + } + + if subscription.WillRenew == "true" { + // They've already completed the renewal process so no need to email them. + // We can zero out the system variable so that this process will work again next year + if prevSentEmail != 0 { + sysVar.Value = "0" + err = a.Srv().Store().System().SaveOrUpdate(sysVar) + if err != nil { + a.Log().Error("Error saving system variable", mlog.Err(err)) + } + } + return + } + + var emailFunc func(email, locale, siteURL string) error + + daysToExpiration := subscription.DaysToExpiration() + + // Only send the email if within the period and it's not already been sent + // This allows the email to send on day 59 if for whatever reason it was unable to on day 60 + if daysToExpiration <= 60 && daysToExpiration > 30 && prevSentEmail != 60 { + emailFunc = a.Srv().EmailService.SendCloudRenewalEmail60 + prevSentEmail = 60 + } else if daysToExpiration <= 30 && daysToExpiration > 7 && prevSentEmail != 30 { + emailFunc = a.Srv().EmailService.SendCloudRenewalEmail30 + prevSentEmail = 30 + } else if daysToExpiration <= 7 && daysToExpiration > 3 && prevSentEmail != 7 { + emailFunc = a.Srv().EmailService.SendCloudRenewalEmail7 + prevSentEmail = 7 + } + + if emailFunc == nil { + return + } + + sysAdmins, aErr := a.getSysAdminsEmailRecipients() + if aErr != nil { + a.Log().Error("Error getting sys admins", mlog.Err(aErr)) + return + } + + numFailed := 0 + for _, admin := range sysAdmins { + err = emailFunc(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL) + if err != nil { + a.Log().Error("Error sending renewal email", mlog.Err(err)) + numFailed += 1 + } + } + + if numFailed == len(sysAdmins) { + // If all emails failed, we don't want to update the system variable + return + } + + updatedSysVar := &model.System{ + Name: model.CloudRenewalEmail, + Value: strconv.FormatInt(prevSentEmail, 10), + } + + err = a.Srv().Store().System().SaveOrUpdate(updatedSysVar) + if err != nil { + a.Log().Error("Error saving system variable", mlog.Err(err)) + } +} diff --git a/server/channels/app/email/email.go b/server/channels/app/email/email.go index 3ef23f74ab..c48620be1f 100644 --- a/server/channels/app/email/email.go +++ b/server/channels/app/email/email.go @@ -1251,6 +1251,99 @@ func (es *Service) SendDelinquencyEmail90(email, locale, siteURL string) error { return nil } +func (es *Service) SendCloudRenewalEmail60(email, locale, siteURL string) error { + T := i18n.GetUserTranslations(locale) + + subject := T("api.templates.cloud_renewal_60.subject") + + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.cloud_renewal_60.title") + data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") + // TODO: use the open delinquency modal action + data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" + data.Props["Button"] = T("api.templates.cloud_renewal.button") + + data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") + data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") + data.Props["Image"] = "payment_processing.png" + + body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body, "CloudRenewal60"); err != nil { + return err + } + + return nil +} + +func (es *Service) SendCloudRenewalEmail30(email, locale, siteURL string) error { + T := i18n.GetUserTranslations(locale) + + subject := T("api.templates.cloud_renewal_30.subject") + + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.cloud_renewal_30.title") + data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") + // TODO: use the open delinquency modal action + data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" + data.Props["Button"] = T("api.templates.cloud_renewal.button") + + data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") + data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") + data.Props["Image"] = "payment_processing.png" + + body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body, "CloudRenewal30"); err != nil { + return err + } + + return nil +} + +func (es *Service) SendCloudRenewalEmail7(email, locale, siteURL string) error { + T := i18n.GetUserTranslations(locale) + + subject := T("api.templates.cloud_renewal_7.subject") + + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.cloud_renewal_7.title") + data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") + // TODO: use the open delinquency modal action + data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" + data.Props["Button"] = T("api.templates.cloud_renewal.button") + + data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") + data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") + data.Props["Image"] = "purchase_alert.png" + + body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body, "CloudRenewal7"); err != nil { + return err + } + + return nil +} + // SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS // to renew the user license func (es *Service) SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error { diff --git a/server/channels/app/email/mocks/ServiceInterface.go b/server/channels/app/email/mocks/ServiceInterface.go index 8597124ea8..deaca07976 100644 --- a/server/channels/app/email/mocks/ServiceInterface.go +++ b/server/channels/app/email/mocks/ServiceInterface.go @@ -128,6 +128,48 @@ func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 stri return r0 } +// SendCloudRenewalEmail30 provides a mock function with given fields: _a0, locale, siteURL +func (_m *ServiceInterface) SendCloudRenewalEmail30(_a0 string, locale string, siteURL string) error { + ret := _m.Called(_a0, locale, siteURL) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(_a0, locale, siteURL) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SendCloudRenewalEmail60 provides a mock function with given fields: _a0, locale, siteURL +func (_m *ServiceInterface) SendCloudRenewalEmail60(_a0 string, locale string, siteURL string) error { + ret := _m.Called(_a0, locale, siteURL) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(_a0, locale, siteURL) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SendCloudRenewalEmail7 provides a mock function with given fields: _a0, locale, siteURL +func (_m *ServiceInterface) SendCloudRenewalEmail7(_a0 string, locale string, siteURL string) error { + ret := _m.Called(_a0, locale, siteURL) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(_a0, locale, siteURL) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error { ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles) diff --git a/server/channels/app/email/service.go b/server/channels/app/email/service.go index af57bc0e7c..adf21e531e 100644 --- a/server/channels/app/email/service.go +++ b/server/channels/app/email/service.go @@ -156,6 +156,9 @@ type ServiceInterface interface { SendDelinquencyEmail60(email, locale, siteURL string) error SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error SendDelinquencyEmail90(email, locale, siteURL string) error + SendCloudRenewalEmail60(email, locale, siteURL string) error + SendCloudRenewalEmail30(email, locale, siteURL string) error + SendCloudRenewalEmail7(email, locale, siteURL string) error SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index b7809de9c7..3fd2777aba 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -3894,6 +3894,21 @@ func (a *OpenTracingAppLayer) DoPostActionWithCookie(c request.CTX, postID strin return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) DoSubscriptionRenewalCheck() { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSubscriptionRenewalCheck") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + a.app.DoSubscriptionRenewalCheck() +} + func (a *OpenTracingAppLayer) DoSystemConsoleRolesCreationMigration() { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSystemConsoleRolesCreationMigration") diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 48e2d56ef8..1394b783cc 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -1415,7 +1415,8 @@ func (s *Server) doLicenseExpirationCheck() { } if license.IsCloud() { - mlog.Debug("Skipping license expiration check for Cloud") + appInstance := New(ServerConnector(s.Channels())) + appInstance.DoSubscriptionRenewalCheck() return } diff --git a/server/i18n/en.json b/server/i18n/en.json index 8804850223..bba5af5b12 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3378,6 +3378,38 @@ "id": "api.team.update_team_scheme.scheme_scope.error", "translation": "Unable to set the scheme to the team because the supplied scheme is not a team scheme." }, + { + "id": "api.templates.cloud_renewal.button", + "translation": "Renew now" + }, + { + "id": "api.templates.cloud_renewal.subtitle", + "translation": "Please renew to avoid any disruption" + }, + { + "id": "api.templates.cloud_renewal_30.subject", + "translation": "Annual bill due in 30 days" + }, + { + "id": "api.templates.cloud_renewal_30.title", + "translation": "Your annual bill is due in 30 days" + }, + { + "id": "api.templates.cloud_renewal_60.subject", + "translation": "Annual subscription renewal in 60 days" + }, + { + "id": "api.templates.cloud_renewal_60.title", + "translation": "Annual subscription renewal in 60 days" + }, + { + "id": "api.templates.cloud_renewal_7.subject", + "translation": "Action Required: Annual subscription renewal in 7 days" + }, + { + "id": "api.templates.cloud_renewal_7.title", + "translation": "You are about to lose access to your workspace in 7 days" + }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Mattermost Upgrade Confirmation" diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index 8c367240be..3dba37d279 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -5,7 +5,10 @@ package model import ( "encoding/json" + "os" + "strconv" "strings" + "time" ) const ( @@ -184,6 +187,21 @@ type Subscription struct { WillRenew string `json:"will_renew"` } +func (s *Subscription) DaysToExpiration() int64 { + now := time.Now().UnixMilli() + // Allows us to base the current time off of an environment variable for testing purposes + if GetServiceEnvironment() == ServiceEnvironmentTest { + if currTime, set := os.LookupEnv("CLOUD_MOCK_CURRENT_TIME"); set { + timeInt, err := strconv.ParseInt(currTime, 10, 64) + if err == nil { + now = time.Unix(timeInt, 0).UnixMilli() + } + } + } + daysToExpiry := (s.EndAt - now) / (1000 * 60 * 60 * 24) + return daysToExpiry +} + // Subscription History model represents true up event in a yearly subscription type SubscriptionHistory struct { ID string `json:"id"` diff --git a/server/public/model/system.go b/server/public/model/system.go index 3a292fe672..f830f05388 100644 --- a/server/public/model/system.go +++ b/server/public/model/system.go @@ -38,6 +38,7 @@ const ( SystemHostedPurchaseNeedsScreening = "HostedPurchaseNeedsScreening" AwsMeteringReportInterval = 1 AwsMeteringDimensionUsageHrs = "UsageHrs" + CloudRenewalEmail = "CloudRenewalEmail" ) const ( diff --git a/server/templates/cloud_renewal_notification.html b/server/templates/cloud_renewal_notification.html new file mode 100644 index 0000000000..0977a8844b --- /dev/null +++ b/server/templates/cloud_renewal_notification.html @@ -0,0 +1,530 @@ +{{define "cloud_renewal_notification"}} + + + + + +
+|
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|
+