Send email when invited to organization. closes PrivateCaptcha/issues#96

This commit is contained in:
Taras Kushnir
2025-10-09 10:21:55 +03:00
parent 8f48156e0b
commit 6064339634
8 changed files with 240 additions and 60 deletions

View File

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

View File

@@ -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
View 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>&#8202;&#8202;</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>&#8202;&#8202;&#8203;</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Ü`
)

View File

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

View File

@@ -10,6 +10,7 @@ var (
APIKeyExpiredTemplate,
WelcomeEmailTemplate,
TwoFactorEmailTemplate,
OrgInvitationTemplate,
}
)

View File

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

View File

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

View File

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