[CLD-6894] Add 60, 30, and 7 day reminder emails for Cloud Renewals (#25883)

* Add email notifications for Cloud Renewals

* Updates

* Updates

* Update app-layers

* make build-templates

* Add ability to set an env variable as a unix timestamp in s as the current date when getting DaysToExpiration

* Add a mechanism to ensure at least one admin receives every email

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com>
This commit is contained in:
Nick Misasi
2024-01-11 13:07:41 -05:00
committed by GitHub
parent 04cf1ed114
commit aafe7439af
15 changed files with 889 additions and 1 deletions

View File

@@ -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)

View File

@@ -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))
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"`

View File

@@ -38,6 +38,7 @@ const (
SystemHostedPurchaseNeedsScreening = "HostedPurchaseNeedsScreening"
AwsMeteringReportInterval = 1
AwsMeteringDimensionUsageHrs = "UsageHrs"
CloudRenewalEmail = "CloudRenewalEmail"
)
const (

View File

@@ -0,0 +1,530 @@
{{define "cloud_renewal_notification"}}
<!-- FILE: cloud_renewal_notification.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,600,700);
.emailBody {
background-color: #F3F3F3
}
.emailBody a {
text-decoration: none !important;
color: #1C58D9;
}
.title div {
font-weight: 600 !important;
font-size: 28px !important;
line-height: 36px !important;
letter-spacing: -0.01em !important;
color: #3F4350 !important;
font-family: Open Sans, sans-serif !important;
}
.subTitle div {
font-size: 16px !important;
line-height: 24px !important;
color: rgba(63, 67, 80, 0.64) !important;
}
.subTitle a {
color: rgb(28, 88, 217) !important;
}
.button a {
background-color: #1C58D9 !important;
font-weight: 600 !important;
font-size: 16px !important;
line-height: 18px !important;
color: #FFFFFF !important;
padding: 15px 24px !important;
}
.button-cloud a {
background-color: #1C58D9 !important;
font-weight: 400 !important;
font-size: 16px !important;
line-height: 18px !important;
color: #FFFFFF !important;
padding: 15px 24px !important;
}
.messageButton a {
background-color: #FFFFFF !important;
border: 1px solid #FFFFFF !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 12px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.info div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 40px 0px !important;
}
.footerTitle div {
font-weight: 600 !important;
font-size: 16px !important;
line-height: 24px !important;
color: #3F4350 !important;
padding: 0px 0px 4px 0px !important;
}
.footerInfo div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px 48px 0px 48px !important;
}
.footerInfo a {
color: #1C58D9 !important;
}
.appDownloadButton a {
background-color: #FFFFFF !important;
border: 1px solid #1C58D9 !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 13px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.emailFooter div {
font-size: 12px !important;
line-height: 16px !important;
color: rgba(63, 67, 80, 0.56) !important;
padding: 8px 24px 8px 24px !important;
}
.postCard {
padding: 0px 24px 40px 24px !important;
}
.messageCard {
background: #FFFFFF !important;
border: 1px solid rgba(61, 60, 64, 0.08) !important;
box-sizing: border-box !important;
box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12) !important;
border-radius: 4px !important;
padding: 32px !important;
}
.messageAvatar img {
width: 32px !important;
height: 32px !important;
padding: 0px !important;
border-radius: 32px !important;
}
.messageAvatarCol {
width: 32px !important;
}
.postNameAndTime {
padding: 0px 0px 4px 0px !important;
display: flex;
}
.senderName {
font-family: Open Sans, sans-serif;
text-align: left !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
}
.time {
font-family: Open Sans, sans-serif;
font-size: 12px;
line-height: 16px;
color: rgba(63, 67, 80, 0.56);
padding: 2px 6px;
align-items: center;
float: left;
}
.channelBg {
background: rgba(63, 67, 80, 0.08);
border-radius: 4px;
display: flex;
padding-left: 4px;
}
.channelLogo {
width: 10px;
height: 10px;
padding: 5px 4px 5px 6px;
float: left;
}
.channelName {
font-family: Open Sans, sans-serif;
font-weight: 600;
font-size: 10px;
line-height: 16px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: rgba(63, 67, 80, 0.64);
padding: 2px 6px 2px 0px;
}
.gmChannelCount {
background-color: rgba(63, 67, 80, 0.2);
padding: 0 5px;
border-radius: 2px;
margin-right: 2px;
}
.senderMessage div {
text-align: left !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px !important;
}
.senderInfoCol {
width: 394px !important;
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
}
}
@media all and (max-width: 540px) and (min-width: 401px) {
.emailBody {
padding: 16px !important;
}
.messageCard {
padding: 16px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
@media all and (max-width: 400px) {
.emailBody {
padding: 0px !important;
}
.footerInfo div {
padding: 0px !important;
}
.messageCard {
padding: 16px !important;
}
.postCard {
padding: 0px 0px 40px 0px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
@media only screen and (min-width:480px) {
.mj-column-per-50 {
width: 100% !important;
max-width: 100% !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#FFFFFF;">
<div class="emailBody" style="background-color: #FFFFFF;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;border-radius:8px;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;border-radius:8px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:132px;">
<img alt height="21" src="{{.Props.SiteURL}}/static/images/logo_email_dark.png" style="border:0;display:block;outline:none;text-decoration:none;height:21.76px;width:100%;font-size:13px;" width="132">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="title" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="text-align: center; font-weight: 600; font-size: 28px; line-height: 36px; letter-spacing: -0.01em; color: #3F4350; font-family: Open Sans, sans-serif;">{{.Props.Title}}</div>
</td>
</tr>
<tr>
<td align="center" class="subTitle" style="font-size:0px;padding:16px 24px 16px 24px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: center; font-size: 16px; line-height: 24px; color: rgba(63, 67, 80, 0.64);">{{.Props.SubTitle}}</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" class="button" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#FFFFFF" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:#FFFFFF;" valign="middle">
<a href="{{.Props.ButtonURL}}" style="display: inline-block; background: #FFFFFF; font-family: Open Sans, sans-serif; margin: 0; text-transform: none; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none; background-color: #1C58D9; font-weight: 600; font-size: 16px; line-height: 18px; color: #FFFFFF; padding: 15px 24px;" target="_blank">
{{.Props.Button}}
</a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:312px;">
<img alt height="auto" src="{{.Props.SiteURL}}/static/images/{{.Props.Image}}" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="312">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E5E5E5;direction:ltr;font-size:0px;padding:44px 0px 24px 32px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:520px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="footerTitle" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: left; font-weight: 600; font-size: 16px; line-height: 24px; color: #3F4350; padding: 0px 0px 4px 0px;">{{.Props.QuestionTitle}}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="font-family:Open Sans, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#3F4350;">{{.Props.QuestionInfo}}
<a href="mailto:{{.Props.SupportEmail}}" style="color: #1C58D9; text-decoration: none;">
{{.Props.SupportEmail}}</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:2px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="emailFooter" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: center; font-size: 12px; line-height: 16px; color: rgba(63, 67, 80, 0.56); padding: 8px 24px 8px 24px;">{{.Props.Organization}}
{{.Props.FooterV2}}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
<mjml>
<mj-head>
<mj-include path="./partials/style.mjml" />
</mj-head>
<mj-body css-class="emailBody" background-color="#FFFFFF">
<mj-wrapper mj-class="email">
<mj-section>
<mj-column>
<mj-image mj-class="logo" src="{{.Props.SiteURL}}/static/images/logo_email_dark.png" />
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text css-class="title" padding="0px">
{{.Props.Title}}
</mj-text>
<mj-text css-class="subTitle" padding="16px 24px 16px 24px">
{{.Props.SubTitle}}
</mj-text>
<mj-button href="{{.Props.ButtonURL}}" padding="0px"
css-class="button">{{.Props.Button}}</mj-button>
</mj-column>
</mj-section>
<mj-section padding="0px">
<mj-column>
<mj-image src="{{.Props.SiteURL}}/static/images/{{.Props.Image}}" width="312px"
padding="0px" />
</mj-column>
</mj-section>
<mj-section padding="44px 0px 24px 32px" border-bottom="1px solid #E5E5E5">
<mj-column>
<mj-text align="left" css-class="footerTitle" padding="0px">
{{.Props.QuestionTitle}}
</mj-text>
<mj-text align="left" font-size="14px" color="#3F4350" line-height="20px" padding="0px">
{{.Props.QuestionInfo}}
<a href="mailto:{{.Props.SupportEmail}}">
{{.Props.SupportEmail}}</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="2px">
<mj-column>
<mj-text css-class="emailFooter" padding="0px">
{{.Props.Organization}}
{{.Props.FooterV2}}
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>