mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-12 00:39:29 -06:00
Send email when invited to organization. closes PrivateCaptcha/issues#96
This commit is contained in:
@@ -53,29 +53,35 @@ func serveExecute(templateBody string, w http.ResponseWriter) error {
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Code int
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
CDNURL string
|
||||
Message string
|
||||
TicketID string
|
||||
APIKeyName string
|
||||
APIKeyPrefix string
|
||||
ExpireDays int
|
||||
APIKeySettingsPath string
|
||||
UserName string
|
||||
email.OrgInvitationContext
|
||||
email.APIKeyExpirationContext
|
||||
// heap of everything else
|
||||
Code int
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
CDNURL string
|
||||
UserName string
|
||||
}{
|
||||
UserName: "John Doe",
|
||||
Code: 123456,
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
CurrentYear: time.Now().Year(),
|
||||
Message: "This is a support request message. Nothing works!",
|
||||
TicketID: "qwerty12345",
|
||||
APIKeyName: "My API Key",
|
||||
APIKeyPrefix: db.APIKeyPrefix + "abcd",
|
||||
ExpireDays: 7,
|
||||
APIKeySettingsPath: "settings?tab=apikeys",
|
||||
APIKeyExpirationContext: email.APIKeyExpirationContext{
|
||||
APIKeyContext: email.APIKeyContext{
|
||||
APIKeyName: "My API Key",
|
||||
APIKeyPrefix: db.APIKeyPrefix + "abcd",
|
||||
APIKeySettingsPath: "settings?tab=apikeys",
|
||||
},
|
||||
ExpireDays: 7,
|
||||
},
|
||||
OrgInvitationContext: email.OrgInvitationContext{
|
||||
//UserName: "John Doe",
|
||||
OrgName: "My Organization",
|
||||
OrgOwnerName: "Pat Smith",
|
||||
OrgOwnerEmail: "john.doe@example.com",
|
||||
OrgURL: "https://portal.privatecaptcha.com/org/5",
|
||||
},
|
||||
UserName: "John Doe",
|
||||
Code: 123456,
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
var htmlBodyTpl bytes.Buffer
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type Mailer interface {
|
||||
SendTwoFactor(ctx context.Context, email string, code int) error
|
||||
SendWelcome(ctx context.Context, email, name string) error
|
||||
SendOrgInvite(ctx context.Context, email, name string, orgName, orgOwnerEmail, orgOwnerName, orgURL string) error
|
||||
}
|
||||
|
||||
type NotificationCondition int
|
||||
|
||||
98
pkg/email/org_html.go
Normal file
98
pkg/email/org_html.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package email
|
||||
|
||||
import "github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
||||
|
||||
type OrgInvitationContext struct {
|
||||
UserName string
|
||||
OrgName string
|
||||
OrgOwnerName string
|
||||
OrgOwnerEmail string
|
||||
OrgURL string
|
||||
}
|
||||
|
||||
var (
|
||||
OrgInvitationTemplate = common.NewEmailTemplate("org-invitation", orgInvitationHTMLTemplate, orgInvitationTextTemplate)
|
||||
)
|
||||
|
||||
const (
|
||||
orgInvitationHTMLTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<link rel="preload" as="image" href="{{.CDNURL}}/portal/img/pc-logo-dark.png" />
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body
|
||||
style='background-color:#ffffff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif'
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="max-width:37.5em;margin:0 auto;padding:20px 0 48px"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width:100%">
|
||||
<td>
|
||||
<img alt="Private Captcha" height="40" src="{{.CDNURL}}/portal/img/pc-logo-dark.png" style="display:block;outline:none;border:none;text-decoration:none" />
|
||||
<p style="font-size:16px;line-height:26px;margin:32px 0 16px">
|
||||
Hello {{.UserName}},
|
||||
</p>
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">
|
||||
<strong>{{.OrgOwnerName}}</strong> (<a href="mailto:{{.OrgOwnerEmail}}">{{.OrgOwnerEmail}}</a>) has invited you to the <strong>{{.OrgName}}</strong> organization in Private Captcha.
|
||||
</p>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top:32px;margin-bottom:32px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{.OrgURL}}"
|
||||
style="border-radius:0.5rem;background-color:rgb(0,0,0);padding-left:20px;padding-right:20px;padding-top:12px;padding-bottom:12px;text-align:center;font-weight:600;font-size:16px;color:rgb(255,255,255);text-decoration-line:none;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px"
|
||||
target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width:500%;mso-text-raise:18" hidden>  </i><![endif]--></span
|
||||
><span
|
||||
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px"
|
||||
>Join the organization</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width:500%" hidden>  ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size:16px;line-height:26px;margin:16px 0">
|
||||
Warmly,<br />The Private Captcha team
|
||||
</p>
|
||||
<hr style="width:100%;border:none;border-top:1px solid #eaeaea;border-color:#cccccc;margin:20px 0" />
|
||||
<p style="font-size:14px;line-height:24px;margin:16px 0;color:#9ca299;margin-bottom:10px">
|
||||
<a href="https://privatecaptcha.com" style="text-decoration:underline;color:#9ca299;">PrivateCaptcha</a> © {{.CurrentYear}} Intmaker OÜ
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
orgInvitationTextTemplate = `Hello {{.UserName}},
|
||||
|
||||
{{.OrgOwnerName}} ({{.OrgOwnerEmail}}) has invited you to the '{{.OrgName}}' organization in Private Captcha.
|
||||
|
||||
Join the organization by following this link: {{.OrgURL}}
|
||||
|
||||
Warmly,
|
||||
The Private Captcha team
|
||||
|
||||
--
|
||||
|
||||
PrivateCaptcha © {{.CurrentYear}} Intmaker OÜ`
|
||||
)
|
||||
@@ -25,3 +25,8 @@ func (sm *StubMailer) SendWelcome(ctx context.Context, email, name string) error
|
||||
slog.InfoContext(ctx, "Sent welcome email", "email", email, "name", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *StubMailer) SendOrgInvite(ctx context.Context, email, name string, orgName, orgOwnerEmail, orgOwnerName, orgURL string) error {
|
||||
slog.InfoContext(ctx, "Sent org invite email", "email", email, "name", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ var (
|
||||
APIKeyExpiredTemplate,
|
||||
WelcomeEmailTemplate,
|
||||
TwoFactorEmailTemplate,
|
||||
OrgInvitationTemplate,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,33 +5,41 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
|
||||
)
|
||||
|
||||
func TestEmailTemplates(t *testing.T) {
|
||||
data := struct {
|
||||
Code int
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
CDNURL string
|
||||
Message string
|
||||
TicketID string
|
||||
APIKeyName string
|
||||
APIKeyPrefix string
|
||||
ExpireDays int
|
||||
APIKeySettingsPath string
|
||||
UserName string
|
||||
OrgInvitationContext
|
||||
APIKeyExpirationContext
|
||||
// heap of everything else
|
||||
Code int
|
||||
PortalURL string
|
||||
CurrentYear int
|
||||
CDNURL string
|
||||
UserName string
|
||||
}{
|
||||
UserName: "John Doe",
|
||||
Code: 123456,
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
CurrentYear: time.Now().Year(),
|
||||
Message: "This is a support request message. Nothing works!",
|
||||
TicketID: "qwerty12345",
|
||||
APIKeyName: "My API Key",
|
||||
APIKeyPrefix: "abcde",
|
||||
ExpireDays: 7,
|
||||
APIKeySettingsPath: "settings?tab=apikeys",
|
||||
APIKeyExpirationContext: APIKeyExpirationContext{
|
||||
APIKeyContext: APIKeyContext{
|
||||
APIKeyName: "My API Key",
|
||||
APIKeyPrefix: db.APIKeyPrefix + "abcd",
|
||||
APIKeySettingsPath: "settings?tab=apikeys",
|
||||
},
|
||||
ExpireDays: 7,
|
||||
},
|
||||
OrgInvitationContext: OrgInvitationContext{
|
||||
//UserName: "John Doe",
|
||||
OrgName: "My Organization",
|
||||
OrgOwnerName: "Pat Smith",
|
||||
OrgOwnerEmail: "john.doe@example.com",
|
||||
OrgURL: "https://portal.privatecaptcha.com/org/5",
|
||||
},
|
||||
UserName: "John Doe",
|
||||
Code: 123456,
|
||||
CDNURL: "https://cdn.privatecaptcha.com",
|
||||
PortalURL: "https://portal.privatecaptcha.com",
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
for _, tpl := range templates {
|
||||
|
||||
@@ -17,26 +17,28 @@ var (
|
||||
)
|
||||
|
||||
type PortalMailer struct {
|
||||
Mailer emailpkg.Sender
|
||||
CDNURL string
|
||||
PortalURL string
|
||||
EmailFrom common.ConfigItem
|
||||
AdminEmail common.ConfigItem
|
||||
ReplyToEmail common.ConfigItem
|
||||
TwofactorTemplate *common.EmailTemplate
|
||||
WelcomeTemplate *common.EmailTemplate
|
||||
Mailer emailpkg.Sender
|
||||
CDNURL string
|
||||
PortalURL string
|
||||
EmailFrom common.ConfigItem
|
||||
AdminEmail common.ConfigItem
|
||||
ReplyToEmail common.ConfigItem
|
||||
TwofactorTemplate *common.EmailTemplate
|
||||
WelcomeTemplate *common.EmailTemplate
|
||||
OrgInviteItemplate *common.EmailTemplate
|
||||
}
|
||||
|
||||
func NewPortalMailer(cdnURL, portalURL string, mailer emailpkg.Sender, cfg common.ConfigStore) *PortalMailer {
|
||||
return &PortalMailer{
|
||||
Mailer: mailer,
|
||||
EmailFrom: cfg.Get(common.EmailFromKey),
|
||||
AdminEmail: cfg.Get(common.AdminEmailKey),
|
||||
ReplyToEmail: cfg.Get(common.ReplyToEmailKey),
|
||||
CDNURL: strings.TrimSuffix(cdnURL, "/"),
|
||||
PortalURL: strings.TrimSuffix(portalURL, "/"),
|
||||
TwofactorTemplate: emailpkg.TwoFactorEmailTemplate,
|
||||
WelcomeTemplate: emailpkg.WelcomeEmailTemplate,
|
||||
Mailer: mailer,
|
||||
EmailFrom: cfg.Get(common.EmailFromKey),
|
||||
AdminEmail: cfg.Get(common.AdminEmailKey),
|
||||
ReplyToEmail: cfg.Get(common.ReplyToEmailKey),
|
||||
CDNURL: strings.TrimSuffix(cdnURL, "/"),
|
||||
PortalURL: strings.TrimSuffix(portalURL, "/"),
|
||||
TwofactorTemplate: emailpkg.TwoFactorEmailTemplate,
|
||||
WelcomeTemplate: emailpkg.WelcomeEmailTemplate,
|
||||
OrgInviteItemplate: emailpkg.OrgInvitationTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,3 +143,56 @@ func (pm *PortalMailer) SendWelcome(ctx context.Context, email, name string) err
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PortalMailer) SendOrgInvite(ctx context.Context, email, name string, orgName, orgOwnerEmail, orgOwnerName, orgURLPath string) error {
|
||||
if len(email) == 0 {
|
||||
return errInvalidEmail
|
||||
}
|
||||
|
||||
data := struct {
|
||||
emailpkg.OrgInvitationContext
|
||||
CurrentYear int
|
||||
CDNURL string
|
||||
}{
|
||||
CDNURL: pm.CDNURL,
|
||||
CurrentYear: time.Now().Year(),
|
||||
OrgInvitationContext: emailpkg.OrgInvitationContext{
|
||||
UserName: name,
|
||||
OrgName: orgName,
|
||||
OrgOwnerName: orgOwnerName,
|
||||
OrgOwnerEmail: orgOwnerEmail,
|
||||
OrgURL: pm.PortalURL + orgURLPath,
|
||||
},
|
||||
}
|
||||
|
||||
htmlBody, err := pm.OrgInviteItemplate.RenderHTML(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
textBody, err := pm.OrgInviteItemplate.RenderText(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := &emailpkg.Message{
|
||||
HTMLBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
Subject: fmt.Sprintf("[%s] You have been invited to the %s organization", common.PrivateCaptcha, data.OrgName),
|
||||
EmailTo: email,
|
||||
EmailFrom: pm.EmailFrom.Value(),
|
||||
NameFrom: common.PrivateCaptchaTeam,
|
||||
}
|
||||
|
||||
olog := slog.With("email", email, "org", orgName)
|
||||
|
||||
if err := pm.Mailer.SendEmail(ctx, msg); err != nil {
|
||||
olog.ErrorContext(ctx, "Failed to send org invite", common.ErrAttr(err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
olog.InfoContext(ctx, "Sent org invite")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -255,6 +255,12 @@ func (s *Server) postOrgMembers(w http.ResponseWriter, r *http.Request) (Model,
|
||||
ou := userToOrgUser(inviteUser, string(dbgen.AccessLevelInvited))
|
||||
renderCtx.Members = append(renderCtx.Members, ou)
|
||||
renderCtx.SuccessMessage = "Invite is sent."
|
||||
|
||||
go common.RunAdHocFunc(common.CopyTraceID(ctx, context.Background()), func(bctx context.Context) error {
|
||||
orgURLPath := s.PartsURL(common.OrgEndpoint, strconv.Itoa(int(org.ID)))
|
||||
return s.Mailer.SendOrgInvite(bctx, inviteUser.Email, common.GuessFirstName(inviteUser.Name),
|
||||
org.Name, user.Email, common.GuessFirstName(user.Name), orgURLPath)
|
||||
})
|
||||
}
|
||||
|
||||
return renderCtx, orgMembersTemplate, nil
|
||||
|
||||
Reference in New Issue
Block a user