[MM-32543] - Add ability to send email to admin that users are trying to invite others (#16882)
* [MM-32543] - Add ability to send email to admin that users are trying to join * Update email template * Feedback impl-1 * Fix test * Feedback impl-2 * Fix error * Feedback impl * Use 413 status code * make i18n-extract Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
d06a62ce64
Коммит
3f3abc3f3d
@@ -35,6 +35,7 @@ func (api *API) InitCloud() {
|
||||
api.BaseRoutes.Cloud.Handle("/subscription", api.ApiSessionRequired(getSubscription)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.ApiSessionRequired(getInvoicesForSubscription)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.ApiSessionRequired(getSubscriptionInvoicePDF)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/limitreached/invite", api.ApiSessionRequired(sendAdminUpgradeRequestEmail)).Methods("POST")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/stats", api.ApiHandler(getSubscriptionStats)).Methods("GET")
|
||||
|
||||
// POST /api/v4/cloud/webhook
|
||||
@@ -389,3 +390,29 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func sendAdminUpgradeRequestEmail(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud {
|
||||
c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", "api.cloud.license_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.App.GetUser(c.App.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := c.App.Cloud().GetSubscription()
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = c.App.SendAdminUpgradeRequestEmail(user.Username, sub); err != nil {
|
||||
c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
@@ -265,6 +265,9 @@ type AppIface interface {
|
||||
SearchAllChannels(term string, opts model.ChannelSearchOpts) (*model.ChannelListWithTeamData, int64, *model.AppError)
|
||||
// SearchAllTeams returns a team list and the total count of the results
|
||||
SearchAllTeams(searchOpts *model.TeamSearch) ([]*model.Team, int64, *model.AppError)
|
||||
// SendAdminUpgradeRequestEmail takes the username of user trying to alert admins and then applies rate limit of n (number of admins) emails per user per day
|
||||
// before sending the emails.
|
||||
SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError
|
||||
// SendNoCardPaymentFailedEmail
|
||||
SendNoCardPaymentFailedEmail() *model.AppError
|
||||
// ServePluginPublicRequest serves public plugin files
|
||||
|
||||
56
app/cloud.go
56
app/cloud.go
@@ -4,6 +4,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
@@ -18,6 +21,59 @@ func (a *App) getSysAdminsEmailRecipients() ([]*model.User, *model.AppError) {
|
||||
return a.GetUsers(userOptions)
|
||||
}
|
||||
|
||||
// SendAdminUpgradeRequestEmail takes the username of user trying to alert admins and then applies rate limit of n (number of admins) emails per user per day
|
||||
// before sending the emails.
|
||||
func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError {
|
||||
if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if subscription != nil && subscription.IsPaidTier == "true" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if a.Srv().EmailService.PerDayEmailRateLimiter == nil {
|
||||
return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("for username=%s", username), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// rate limit based on username as key
|
||||
rateLimited, result, err := a.Srv().EmailService.PerDayEmailRateLimiter.RateLimit(username, 1)
|
||||
if err != nil {
|
||||
return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("username=%s, error=%v", username, err), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if rateLimited {
|
||||
return model.NewAppError("app.SendAdminUpgradeRequestEmail",
|
||||
"app.email.rate_limit_exceeded.app_error", map[string]interface{}{"RetryAfter": result.RetryAfter.String(), "ResetAfter": result.ResetAfter.String()},
|
||||
fmt.Sprintf("username=%s, retry_after_secs=%f, reset_after_secs=%f",
|
||||
username, result.RetryAfter.Seconds(), result.ResetAfter.Seconds()),
|
||||
http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
sysAdmins, e := a.getSysAdminsEmailRecipients()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
// we want to at least have one email sent out to an admin
|
||||
countNotOks := 0
|
||||
|
||||
for admin := range sysAdmins {
|
||||
ok, err := a.Srv().EmailService.SendUpgradeEmail(username, sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL)
|
||||
if !ok || err != nil {
|
||||
a.Log().Error("Error sending upgrade request email", mlog.Err(err))
|
||||
countNotOks++
|
||||
}
|
||||
}
|
||||
|
||||
// if not even one admin got an email, we consider that this operation errored
|
||||
if countNotOks == len(sysAdmins) {
|
||||
return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CheckAndSendUserLimitWarningEmails() *model.AppError {
|
||||
if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) {
|
||||
// Not cloud instance, do nothing
|
||||
|
||||
61
app/email.go
61
app/email.go
@@ -41,37 +41,49 @@ func condenseSiteURL(siteURL string) string {
|
||||
}
|
||||
|
||||
type EmailService struct {
|
||||
srv *Server
|
||||
EmailRateLimiter *throttled.GCRARateLimiter
|
||||
EmailBatching *EmailBatchingJob
|
||||
srv *Server
|
||||
PerHourEmailRateLimiter *throttled.GCRARateLimiter
|
||||
PerDayEmailRateLimiter *throttled.GCRARateLimiter
|
||||
EmailBatching *EmailBatchingJob
|
||||
}
|
||||
|
||||
func NewEmailService(srv *Server) (*EmailService, error) {
|
||||
service := &EmailService{srv: srv}
|
||||
if err := service.setupInviteEmailRateLimiting(); err != nil {
|
||||
if err := service.setUpRateLimiters(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service.InitEmailBatching()
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (es *EmailService) setupInviteEmailRateLimiting() error {
|
||||
func (es *EmailService) setUpRateLimiters() error {
|
||||
store, err := memstore.New(emailRateLimitingMemstoreSize)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to setup email rate limiting memstore.")
|
||||
}
|
||||
|
||||
quota := throttled.RateQuota{
|
||||
perHourQuota := throttled.RateQuota{
|
||||
MaxRate: throttled.PerHour(emailRateLimitingPerHour),
|
||||
MaxBurst: emailRateLimitingMaxBurst,
|
||||
}
|
||||
|
||||
rateLimiter, err := throttled.NewGCRARateLimiter(store, quota)
|
||||
if err != nil || rateLimiter == nil {
|
||||
perDayQuota := throttled.RateQuota{
|
||||
MaxRate: throttled.PerDay(1),
|
||||
MaxBurst: 0,
|
||||
}
|
||||
|
||||
perHourRateLimiter, err := throttled.NewGCRARateLimiter(store, perHourQuota)
|
||||
if err != nil || perHourRateLimiter == nil {
|
||||
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
|
||||
}
|
||||
|
||||
es.EmailRateLimiter = rateLimiter
|
||||
perDayRateLimiter, err := throttled.NewGCRARateLimiter(store, perDayQuota)
|
||||
if err != nil || perDayRateLimiter == nil {
|
||||
return errors.Wrap(err, "Unable to setup per day email rate limiting GCRA rate limiter.")
|
||||
}
|
||||
|
||||
es.PerHourEmailRateLimiter = perHourRateLimiter
|
||||
es.PerDayEmailRateLimiter = perDayRateLimiter
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -324,10 +336,10 @@ func (es *EmailService) sendMfaChangeEmail(email string, activated bool, locale,
|
||||
}
|
||||
|
||||
func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string) *model.AppError {
|
||||
if es.EmailRateLimiter == nil {
|
||||
if es.PerHourEmailRateLimiter == nil {
|
||||
return model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", senderUserId, team.Id), http.StatusInternalServerError)
|
||||
}
|
||||
rateLimited, result, err := es.EmailRateLimiter.RateLimit(senderUserId, len(invites))
|
||||
rateLimited, result, err := es.PerHourEmailRateLimiter.RateLimit(senderUserId, len(invites))
|
||||
if err != nil {
|
||||
return model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", senderUserId, team.Id, err), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -383,10 +395,10 @@ func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, se
|
||||
}
|
||||
|
||||
func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string) *model.AppError {
|
||||
if es.EmailRateLimiter == nil {
|
||||
if es.PerHourEmailRateLimiter == nil {
|
||||
return model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", senderUserId, team.Id), http.StatusInternalServerError)
|
||||
}
|
||||
rateLimited, result, err := es.EmailRateLimiter.RateLimit(senderUserId, len(invites))
|
||||
rateLimited, result, err := es.PerHourEmailRateLimiter.RateLimit(senderUserId, len(invites))
|
||||
if err != nil {
|
||||
return model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", senderUserId, team.Id, err), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -621,6 +633,29 @@ func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SendUpgradeEmail formats an email template and sends an email to an admin specified in the email arg
|
||||
func (es *EmailService) SendUpgradeEmail(user, email, locale, siteURL string) (bool, *model.AppError) {
|
||||
T := utils.GetUserTranslations(locale)
|
||||
|
||||
subject := T("api.templates.upgrade_request_subject")
|
||||
|
||||
bodyPage := es.newEmailTemplate("cloud_upgrade_request_email", locale)
|
||||
bodyPage.Props["Title"] = T("api.templates.upgrade_request_title", map[string]interface{}{"UserName": user})
|
||||
bodyPage.Props["Info4"] = T("api.templates.upgrade_request_info4")
|
||||
bodyPage.Props["Info5"] = T("api.templates.at_limit_info5")
|
||||
bodyPage.Props["BillingPath"] = "admin_console/billing/subscription"
|
||||
bodyPage.Props["SiteURL"] = siteURL
|
||||
bodyPage.Props["Button"] = T("api.templates.upgrade_mattermost_cloud")
|
||||
bodyPage.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
|
||||
bodyPage.Props["Footer"] = T("api.templates.copyright")
|
||||
|
||||
if err := es.sendMail(email, subject, bodyPage.Render()); err != nil {
|
||||
return false, model.NewAppError("SendUpgradeEmail", "api.user.send_upgrade_request_email.error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (es *EmailService) SendOverUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) {
|
||||
T := utils.GetUserTranslations(locale)
|
||||
|
||||
|
||||
@@ -61,3 +61,38 @@ func TestSendInviteEmailRateLimits(t *testing.T) {
|
||||
assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id)
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode)
|
||||
}
|
||||
|
||||
func TestSendAdminUpgradeRequestEmail(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
mockSubscription := &model.Subscription{
|
||||
ID: "MySubscriptionID",
|
||||
CustomerID: "MyCustomer",
|
||||
ProductID: "SomeProductId",
|
||||
AddOns: []string{},
|
||||
StartAt: 1000000000,
|
||||
EndAt: 2000000000,
|
||||
CreateAt: 1000000000,
|
||||
Seats: 100,
|
||||
DNS: "some.dns.server",
|
||||
IsPaidTier: "false",
|
||||
}
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ExperimentalSettings.CloudUserLimit = 10
|
||||
})
|
||||
|
||||
err := th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription)
|
||||
require.Nil(t, err)
|
||||
|
||||
err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser2.Username, mockSubscription)
|
||||
require.Nil(t, err)
|
||||
|
||||
// second attempt by the same user to send emails is blocked by rate limiter
|
||||
err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription)
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error")
|
||||
}
|
||||
|
||||
@@ -13220,6 +13220,28 @@ func (a *OpenTracingAppLayer) SendAckToPushProxy(ack *model.PushNotificationAck)
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendAdminUpgradeRequestEmail")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.SendAdminUpgradeRequestEmail(username, subscription)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SendAutoResponse(channel *model.Channel, receiver *model.User, post *model.Post) (bool, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendAutoResponse")
|
||||
|
||||
24
i18n/en.json
24
i18n/en.json
@@ -2606,6 +2606,10 @@
|
||||
"id": "api.templates.at_limit_info2",
|
||||
"translation": "Alternatively, you can disable users in the Admin Console to open up spots for more users or stay below the free user limit."
|
||||
},
|
||||
{
|
||||
"id": "api.templates.at_limit_info5",
|
||||
"translation": "Alternatively, you can disable users in the Admin Console to open up spots for users and stay below the free user limit."
|
||||
},
|
||||
{
|
||||
"id": "api.templates.at_limit_subject",
|
||||
"translation": "Mattermost Cloud User Limit Reached"
|
||||
@@ -2962,6 +2966,18 @@
|
||||
"id": "api.templates.upgrade_mattermost_cloud",
|
||||
"translation": "Upgrade"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.upgrade_request_info4",
|
||||
"translation": "Because your workspace has reached the user limit for the free version of Mattermost cloud, invitations cannot be sent. Upgrade now to allow more users to join your workspace."
|
||||
},
|
||||
{
|
||||
"id": "api.templates.upgrade_request_subject",
|
||||
"translation": "Mattermost user request upgrade of workspace"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.upgrade_request_title",
|
||||
"translation": "{{ .UserName }} would like to invite members to your workspace"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.user_access_token_body.info",
|
||||
"translation": "A personal access token was added to your account on {{ .SiteURL }}. They can be used to access {{.SiteName}} with your account."
|
||||
@@ -3446,6 +3462,10 @@
|
||||
"id": "api.user.send_sign_in_change_email_and_forget.error",
|
||||
"translation": "Failed to send update password email successfully"
|
||||
},
|
||||
{
|
||||
"id": "api.user.send_upgrade_request_email.error",
|
||||
"translation": "Failed to send email to user limit notification to admin"
|
||||
},
|
||||
{
|
||||
"id": "api.user.send_user_access_token.error",
|
||||
"translation": "Failed to send \"Personal access token added\" email successfully"
|
||||
@@ -5730,6 +5750,10 @@
|
||||
"id": "app.user.search.app_error",
|
||||
"translation": "Unable to find any user matching the search parameters."
|
||||
},
|
||||
{
|
||||
"id": "app.user.send_emails.app_error",
|
||||
"translation": "No emails were successfully sent"
|
||||
},
|
||||
{
|
||||
"id": "app.user.update.find.app_error",
|
||||
"translation": "Unable to find the existing account to update."
|
||||
|
||||
@@ -5972,3 +5972,13 @@ func (c *Client4) UpdateThreadFollowForUser(userId, teamId, threadId string, sta
|
||||
|
||||
return BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client4) SendAdminUpgradeRequestEmail() *Response {
|
||||
r, appErr := c.DoApiPost(c.GetCloudRoute()+"/subscription/limitreached/invite", "")
|
||||
if appErr != nil {
|
||||
return BuildErrorResponse(r, appErr)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r)
|
||||
}
|
||||
|
||||
88
templates/cloud_upgrade_request_email.html
Обычный файл
88
templates/cloud_upgrade_request_email.html
Обычный файл
@@ -0,0 +1,88 @@
|
||||
{{define "cloud_upgrade_request_email"}}
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="margin-top: 20px; line-height: 1.7; color: #555;font-family: Arial; font-style: normal; font-weight: bold;">
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="max-width: 660px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF;">
|
||||
<tr>
|
||||
<td style="border: 1px solid #ddd;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 20px 10px; text-align:left;">
|
||||
<img src="{{.Props.SiteURL}}/static/images/logo-email.png" style="opacity: 0.5" width="130px" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="border-collapse: collapse; max-width: 443px">
|
||||
<tr style="width: 443px">
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
style="padding: 20px 0 0; text-align: center; margin: 0 auto">
|
||||
<tr>
|
||||
<td style="padding: 0 0 20px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0"
|
||||
width="371px">
|
||||
<tr>
|
||||
<td>
|
||||
<h2 style="margin-bottom: 0; text-align: center; color: #3D3C40; font-weight: bold; margin-top: 10px; line-height: 32px; font-size: 28px; font-family: Arial">{{ .Props.Title }}</h2>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-weight: normal; color: #3D3C40; font-size: 16px; line-height: 24px; font-family: Arial">
|
||||
{{ .Props.Info4 }}</p>
|
||||
<p style="margin: 31px 0 31px">
|
||||
<a href="{{.Props.SiteURL}}/{{.Props.BillingPath}}" target="_blank"
|
||||
style="font-weight: normal; border-radius: 4px; background: #166DE0; color: #fff;outline: none; min-width: 200px; padding: 11px 24px 11px 24px; font-size: 14px; font-family: Arial; cursor: pointer; -webkit-appearance: none;text-decoration: none; margin-top: 16px;">{{ .Props.Button }}</a>
|
||||
</p>
|
||||
<p style="font-weight: normal; line-height: 20px; color: #3D3C40; margin-bottom: 24px; font-size: 14px; font-family: Arial">
|
||||
{{ .Props.Info5 }}</p>
|
||||
<img src="{{.Props.SiteURL}}/static/images/credit-card-empty-state.png"
|
||||
width="320px" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
|
||||
<tr>
|
||||
<td style="border-bottom: 1px solid #E5E5E5"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="max-width: 443px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="font-family: Arial; padding: 20px 20px 24px 48px; text-align:left;">
|
||||
<h3 style="margin-bottom: 0;">Questions?</h3>
|
||||
<p style="font-weight: normal; margin-top: 0;">{{ .Props.EmailUs }} <a href="mailto:feedback@mattermost.com">feedback@mattermost.com</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
|
||||
<tr>
|
||||
<td style="border-bottom: 1px solid #E5E5E5"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="font-family: Arial; line-height: 16px; font-size: 12px; background: #FFF;">
|
||||
<tr>
|
||||
<td
|
||||
style="font-weight: normal; text-align: center;color: #AAA;font-size: 11px;padding-bottom: 10px;padding: 0;line-height: 16px;padding-bottom: 48px;padding-top: 4px;">
|
||||
<p>
|
||||
{{.Props.Organization}}<br>
|
||||
{{.Props.Footer}}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{end}}
|
||||
Ссылка в новой задаче
Block a user