From e13ff134ed4c8baf9764bc68708d1008178b284a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 30 Jan 2026 17:31:26 +0400 Subject: [PATCH] Extract email presentation configuration layer to streamline email branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- packages/emails/emails.go | 203 ++++++++++++------ packages/emails/src/MagicLink.tsx | 4 +- .../emails/src/components/EmailLayout.tsx | 10 +- packages/emails/src/components/Logo.tsx | 13 ++ packages/emails/src/components/ProboLogo.tsx | 13 -- pkg/iam/account_service.go | 19 +- pkg/iam/auth_service.go | 76 +++---- pkg/iam/compliance_page_service.go | 175 +++++++++++++++ pkg/iam/organization_service.go | 18 +- pkg/iam/service.go | 18 +- pkg/probo/document_service.go | 28 +-- pkg/probo/framework_service.go | 6 +- pkg/probo/trust_center_access_service.go | 58 +---- pkg/probo/trust_center_service.go | 90 ++++++++ pkg/server/api/trust/v1/v1_resolver.go | 37 +--- pkg/trust/trust_center_access_service.go | 71 ++---- pkg/trust/trust_center_service.go | 92 ++++++++ 17 files changed, 600 insertions(+), 331 deletions(-) create mode 100644 packages/emails/src/components/Logo.tsx delete mode 100644 packages/emails/src/components/ProboLogo.tsx create mode 100644 pkg/iam/compliance_page_service.go diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 4db50d8db..688dabd3c 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -21,14 +21,73 @@ import ( htmltemplate "html/template" texttemplate "text/template" "time" + + "go.probo.inc/probo/pkg/baseurl" ) //go:embed dist var Templates embed.FS -const ( - logoURLPath = "/logos/probo.png" +type ( + PresenterConfig struct { + BaseURL string + SenderCompanyName string + SenderCompanyWebsiteURL string + SenderCompanyLogoURL string + SenderCompanyHeadquarterAddress string + } + PresenterVariables struct { + // Static variables + BaseURL string + SenderCompanyName string + SenderCompanyWebsiteURL string + SenderCompanyLogoURL string + SenderCompanyHeadquarterAddress string + + // Common variables + RecipientFullName string + // Not to confuse with the SenderCompanyName, which is the brand of the product being used + OrganizationName string + } + + Presenter struct { + variables PresenterVariables + } +) + +func DefaultPresenterConfig(baseURL string) PresenterConfig { + return PresenterConfig{ + BaseURL: baseURL, + SenderCompanyName: "Probo", + SenderCompanyWebsiteURL: "https://www.getprobo.com", + SenderCompanyLogoURL: baseurl.MustParse(baseURL).WithPath("/logos/probo.png").MustString(), + SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US", + } +} + +func NewPresenterFromConfig(cfg PresenterConfig, fullName string) *Presenter { + return &Presenter{ + variables: PresenterVariables{ + BaseURL: cfg.BaseURL, + SenderCompanyName: cfg.SenderCompanyName, + SenderCompanyWebsiteURL: cfg.SenderCompanyWebsiteURL, + SenderCompanyLogoURL: cfg.SenderCompanyLogoURL, + SenderCompanyHeadquarterAddress: cfg.SenderCompanyHeadquarterAddress, + + RecipientFullName: fullName, + }, + } +} + +func NewPresenter(baseURL string, fullName string) *Presenter { + return NewPresenterFromConfig( + DefaultPresenterConfig(baseURL), + fullName, + ) +} + +const ( subjectConfirmEmail = "Confirm your email address" subjectPasswordReset = "Reset your password" subjectInvitation = "Invitation to join %s on Probo" @@ -37,7 +96,7 @@ const ( subjectFrameworkExport = "Your framework export is ready" subjectTrustCenterAccess = "Compliance Page Access Invitation - %s" subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s" - subjectMagicLink = "Connect to Probo" + subjectMagicLink = "Connect to %s" ) var ( @@ -61,154 +120,158 @@ var ( magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl")) ) -func RenderConfirmEmail(baseURL, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderConfirmEmail(confirmationURLPath string, confirmationTokenParam string) (subject string, textBody string, htmlBody *string, err error) { + confirmationUrl := baseurl. + MustParse(p.variables.BaseURL). + WithPath(confirmationURLPath). + WithQuery("token", confirmationTokenParam). + MustString() + data := struct { - FullName string + PresenterVariables ConfirmationUrl string - LogoURL string }{ - FullName: fullName, - ConfirmationUrl: confirmationUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + ConfirmationUrl: confirmationUrl, } textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data) return subjectConfirmEmail, textBody, htmlBody, err } -func RenderPasswordReset(baseURL, fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderPasswordReset(resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) { + resetUrl := baseurl. + MustParse(p.variables.BaseURL). + WithPath(resetPasswordURLPath). + WithQuery("token", resetPasswordToken). + MustString() + data := struct { - FullName string + PresenterVariables ResetUrl string - LogoURL string }{ - FullName: fullName, - ResetUrl: resetUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + ResetUrl: resetUrl, } textBody, htmlBody, err = renderEmail(passwordResetTextTemplate, passwordResetHTMLTemplate, data) return subjectPasswordReset, textBody, htmlBody, err } -func RenderInvitation(baseURL, fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderInvitation(invitationURLPath string, invitationToken string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + invitationURL := baseurl. + MustParse(p.variables.BaseURL). + WithPath(invitationURLPath). + WithQuery("token", invitationToken). + WithQuery("fullName", p.variables.RecipientFullName). + MustString() + data := struct { - FullName string - OrganizationName string + PresenterVariables InvitationUrl string - LogoURL string + OrganizationName string }{ - FullName: fullName, - OrganizationName: organizationName, - InvitationUrl: invitationUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + InvitationUrl: invitationURL, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(invitationTextTemplate, invitationHTMLTemplate, data) return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err } -func RenderDocumentSigning(baseURL, fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderDocumentSigning(signinURLPath string, token string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + signingURL := baseurl.MustParse(p.variables.BaseURL). + WithPath(signinURLPath). + WithQuery("token", token). + MustString() + data := struct { - FullName string - OrganizationName string + PresenterVariables SigningUrl string - LogoURL string + OrganizationName string }{ - FullName: fullName, - OrganizationName: organizationName, - SigningUrl: signingUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + SigningUrl: signingURL, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data) return fmt.Sprintf(subjectDocumentSigning, organizationName), textBody, htmlBody, err } -func RenderDocumentExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderDocumentExport(downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { - FullName string + PresenterVariables DownloadUrl string - LogoURL string }{ - FullName: fullName, - DownloadUrl: downloadUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + DownloadUrl: downloadUrl, } textBody, htmlBody, err = renderEmail(documentExportTextTemplate, documentExportHTMLTemplate, data) return subjectDocumentExport, textBody, htmlBody, err } -func RenderFrameworkExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderFrameworkExport(downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { data := struct { - FullName string + PresenterVariables DownloadUrl string - LogoURL string }{ - FullName: fullName, - DownloadUrl: downloadUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + DownloadUrl: downloadUrl, } textBody, htmlBody, err = renderEmail(frameworkExportTextTemplate, frameworkExportHTMLTemplate, data) return subjectFrameworkExport, textBody, htmlBody, err } -func RenderTrustCenterAccess(baseURL, fullName, organizationName, accessUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderTrustCenterAccess(organizationName string) (subject string, textBody string, htmlBody *string, err error) { data := struct { - FullName string + PresenterVariables OrganizationName string - AccessUrl string - LogoURL string }{ - FullName: fullName, - OrganizationName: organizationName, - AccessUrl: accessUrl, - LogoURL: baseURL + logoURLPath, + PresenterVariables: p.variables, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(trustCenterAccessTextTemplate, trustCenterAccessHTMLTemplate, data) return fmt.Sprintf(subjectTrustCenterAccess, organizationName), textBody, htmlBody, err } -func RenderTrustCenterDocumentAccessRejected( - baseURL string, - fullName string, - organizationName string, +func (p *Presenter) RenderTrustCenterDocumentAccessRejected( fileNames []string, + organizationName string, ) (subject string, textBody string, htmlBody *string, err error) { data := struct { - FullName string - OrganizationName string - LogoURL string + PresenterVariables FileNames []string + OrganizationName string }{ - FullName: fullName, - OrganizationName: organizationName, - LogoURL: baseURL + logoURLPath, - FileNames: fileNames, + PresenterVariables: p.variables, + FileNames: fileNames, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(trustCenterDocumentAccessRejectedTextTemplate, trustCenterDocumentAccessRejectedHTMLTemplate, data) return fmt.Sprintf(subjectTrustCenterDocumentAccessRejected, organizationName), textBody, htmlBody, err } -func RenderMagicLink(baseURL, fullName, magicLinkUrl string, tokenDuration time.Duration) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderMagicLink(magicLinkUrlPath string, tokenDuration time.Duration, organizationName string) (subject string, textBody string, htmlBody *string, err error) { data := struct { - FullName string + PresenterVariables MagicLinkURL string - LogoURL string DurationInMinutes int + OrganizationName string }{ - FullName: fullName, - MagicLinkURL: magicLinkUrl, - LogoURL: baseURL + logoURLPath, - DurationInMinutes: int(tokenDuration.Minutes()), + PresenterVariables: p.variables, + MagicLinkURL: baseurl.MustParse(p.variables.BaseURL).WithPath(magicLinkUrlPath).MustString(), + DurationInMinutes: int(tokenDuration.Minutes()), + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(magicLinkTextTemplate, magicLinkHTMLTemplate, data) - return subjectMagicLink, textBody, htmlBody, err + return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err } func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) { diff --git a/packages/emails/src/MagicLink.tsx b/packages/emails/src/MagicLink.tsx index 6e45e70d1..9155673e1 100644 --- a/packages/emails/src/MagicLink.tsx +++ b/packages/emails/src/MagicLink.tsx @@ -10,11 +10,11 @@ import EmailLayout, { export const MagicLink = () => { return ( - Please use this link to connect to Probo: + {"Please use this link to connect to {{.OrganizationName}}'s Compliance Page:"}
diff --git a/packages/emails/src/components/EmailLayout.tsx b/packages/emails/src/components/EmailLayout.tsx index 5ad4f1215..41bdc4add 100644 --- a/packages/emails/src/components/EmailLayout.tsx +++ b/packages/emails/src/components/EmailLayout.tsx @@ -11,7 +11,7 @@ import { Text, } from '@react-email/components'; import * as React from 'react'; -import { ProboLogo } from './ProboLogo'; +import { Logo } from './Logo'; interface EmailLayoutProps { subject: string; @@ -33,19 +33,19 @@ export const EmailLayout = ({
- - + +
- Hi {'{{.FullName}}'}, + Hi {'{{.RecipientFullName}}'}, {children}
- Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US + {"{{.SenderCompanyHeadquarterAddress}}"}
diff --git a/packages/emails/src/components/Logo.tsx b/packages/emails/src/components/Logo.tsx new file mode 100644 index 000000000..23d646f4b --- /dev/null +++ b/packages/emails/src/components/Logo.tsx @@ -0,0 +1,13 @@ +import { Img } from '@react-email/components'; +import * as React from 'react'; + +export function Logo() { + return ( + {{.SenderCompanyName}} + ); +} diff --git a/packages/emails/src/components/ProboLogo.tsx b/packages/emails/src/components/ProboLogo.tsx deleted file mode 100644 index e9505267b..000000000 --- a/packages/emails/src/components/ProboLogo.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Img } from '@react-email/components'; -import * as React from 'react'; - -export function ProboLogo() { - return ( - Probo - ); -} diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index b86d1292d..6d6d923e4 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -22,7 +22,6 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" @@ -86,16 +85,6 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req return fmt.Errorf("cannot generate confirmation token: %w", err) } - base, err := baseurl.Parse(s.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } - - confirmationUrl := base. - WithPath("/auth/verify-email"). - WithQuery("token", confirmationToken). - MustString() - return s.pg.WithTx( ctx, func(tx pg.Conn) error { @@ -127,11 +116,9 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req return fmt.Errorf("cannot update identity: %w", err) } - subject, textBody, htmlBody, err := emails.RenderConfirmEmail( - s.baseURL, - identity.FullName, - confirmationUrl, - ) + emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) + + subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) if err != nil { return fmt.Errorf("cannot render confirmation email: %w", err) } diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 067dbce77..5200e42a3 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -23,7 +23,6 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" @@ -64,8 +63,11 @@ type ( } SendMagicLinkRequest struct { - Email mail.Addr - BaseURL *baseurl.URLBuilder + Email mail.Addr + URLPath string + OrganizationID gid.GID + // If users tries to connect to compliance page, we must brand the emails accordingly + CompliancePageID *gid.GID } PasswordResetData struct { @@ -276,16 +278,6 @@ func (s AuthService) SendPasswordResetInstructionByEmail( return fmt.Errorf("cannot generate password reset token: %w", err) } - base, err := baseurl.Parse(s.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } - - resetPasswordUrl := base. - WithPath("/auth/reset-password"). - WithQuery("token", token). - MustString() - return s.pg.WithTx( ctx, func(tx pg.Conn) error { @@ -298,10 +290,11 @@ func (s AuthService) SendPasswordResetInstructionByEmail( return fmt.Errorf("cannot load identity: %w", err) } - subject, textBody, htmlBody, err := emails.RenderPasswordReset( - s.baseURL, - identity.FullName, - resetPasswordUrl, + emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) + + subject, textBody, htmlBody, err := emailPresenter.RenderPasswordReset( + "/auth/reset-password", + token, ) if err != nil { return fmt.Errorf("cannot render password reset email: %w", err) @@ -413,24 +406,9 @@ func (s AuthService) CreateIdentityWithPassword( return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err) } - base, err := baseurl.Parse(s.baseURL) - if err != nil { - return nil, nil, fmt.Errorf("cannot parse base URL: %w", err) - } + emailPresenter := emails.NewPresenter(s.baseURL, req.FullName) - confirmationUrl, err := base. - WithPath("/auth/verify-email"). - WithQuery("token", confirmationToken). - String() - if err != nil { - return nil, nil, fmt.Errorf("cannot build confirmation URL: %w", err) - } - - subject, textBody, htmlBody, err := emails.RenderConfirmEmail( - s.baseURL, - req.FullName, - confirmationUrl, - ) + subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) if err != nil { return nil, nil, fmt.Errorf("cannot render confirmation email: %w", err) } @@ -560,10 +538,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques return fmt.Errorf("cannot generate magic link token: %w", err) } - magicLinkURL := req.BaseURL. - WithQuery("token", tokenString). - MustString() - return s.pg.WithTx( ctx, func(tx pg.Conn) error { @@ -579,9 +553,9 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques fullName := req.Email.Username() identity := &coredata.Identity{} + organization := &coredata.Organization{} - err := identity.LoadByEmail(ctx, tx, req.Email) - if err == nil { + if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil { if identity.FullName != "" { fullName = identity.FullName } @@ -591,11 +565,25 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques } } - subject, textBody, htmlBody, err := emails.RenderMagicLink( - s.baseURL, - fullName, - magicLinkURL, + if err := organization.LoadByID(ctx, tx, coredata.NewNoScope(), req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL) + if req.CompliancePageID != nil { + var err error + emailPresenterCfg, err = s.CompliancePageService.EmailPresenterConfig(ctx, *req.CompliancePageID) + if err != nil { + return fmt.Errorf("cannot get compliance page email presenter config: %w", err) + } + } + + emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, fullName) + + subject, textBody, htmlBody, err := emailPresenter.RenderMagicLink( + req.URLPath, s.magicLinkTokenValidity, + organization.Name, ) if err != nil { return fmt.Errorf("cannot render magic link email: %w", err) diff --git a/pkg/iam/compliance_page_service.go b/pkg/iam/compliance_page_service.go new file mode 100644 index 000000000..d700c4c84 --- /dev/null +++ b/pkg/iam/compliance_page_service.go @@ -0,0 +1,175 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/packages/emails" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +type ( + CompliancePageService struct { + *Service + } +) + +func NewCompliancePageService(svc *Service) *CompliancePageService { + return &CompliancePageService{Service: svc} +} + +func (s *CompliancePageService) GenerateLogoURL( + ctx context.Context, + compliancePageID gid.GID, + expiresIn time.Duration, +) (*string, error) { + file := &coredata.File{} + compliancePage := &coredata.TrustCenter{} + + scope := coredata.NewScopeFromObjectID(compliancePageID) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil { + return fmt.Errorf("cannot load compliance page: %w", err) + } + + if compliancePage.LogoFileID == nil { + return nil + } + + if err := file.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil { + return fmt.Errorf("cannot load file: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + if compliancePage.LogoFileID == nil { + return nil, nil + } + + if file.FileKey == "" { + return nil, nil + } + + presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn) + if err != nil { + return nil, fmt.Errorf("cannot generate file URL: %w", err) + } + + return &presignedURL, nil +} + +func (s *CompliancePageService) EmailPresenterConfig(ctx context.Context, compliancePageID gid.GID) (emails.PresenterConfig, error) { + var ( + compliancePage = &coredata.TrustCenter{} + organization = &coredata.Organization{} + customDomain *coredata.CustomDomain + logoFile = &coredata.File{} + emailPresenterCfg = emails.DefaultPresenterConfig(s.baseURL) + ) + + scope := coredata.NewScopeFromObjectID(compliancePageID) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil { + return fmt.Errorf("cannot load compliance page: %w", err) + } + + if compliancePage.LogoFileID != nil { + if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil { + return fmt.Errorf("cannot load logoFile: %w", err) + } + } + + if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + customDomain = &coredata.CustomDomain{} + if err := customDomain.LoadByOrganizationID(ctx, conn, scope, s.encryptionKey, organization.ID); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load custom domain: %w", err) + } + } + + return nil + }, + ) + if err != nil { + return emailPresenterCfg, err + } + + parsedBaseURL, err := url.Parse(s.baseURL) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot parse base URL: %w", err) + } + + baseURL := url.URL{ + Scheme: parsedBaseURL.Scheme, + Host: parsedBaseURL.Host, + Path: "/trust/" + compliancePage.Slug, + } + + if customDomain != nil && customDomain.SSLStatus == coredata.CustomDomainSSLStatusActive { + baseURL.Host = customDomain.Domain + baseURL.Scheme = "https" + baseURL.Path = "" + } + + emailPresenterCfg.BaseURL = baseURL.String() + + if compliancePage.LogoFileID != nil { + if logoFile.FileKey == "" { + return emailPresenterCfg, nil + } + + // If logo exists, then we will brand the emails with the org as a sender + + presignedURL, err := s.fm.GenerateFileUrl(ctx, logoFile, 1*time.Hour) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + } + + emailPresenterCfg.SenderCompanyLogoURL = presignedURL + + emailPresenterCfg.SenderCompanyName = organization.Name + + if organization.WebsiteURL != nil { + emailPresenterCfg.SenderCompanyWebsiteURL = *organization.WebsiteURL + } + + if organization.HeadquarterAddress != nil { + emailPresenterCfg.SenderCompanyHeadquarterAddress = *organization.HeadquarterAddress + } + } + + return emailPresenterCfg, nil +} diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 5b7c0b9c8..765628e92 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -24,7 +24,6 @@ import ( "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filevalidation" "go.probo.inc/probo/pkg/gid" @@ -465,21 +464,12 @@ func (s *OrganizationService) InviteMember( return fmt.Errorf("cannot generate invitation token: %w", err) } - baseurl, err := baseurl.Parse(s.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } + emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) - invitationURL := baseurl.WithPath("/auth/signup-from-invitation"). - WithQuery("token", invitationToken). - WithQuery("fullName", invitation.FullName). - MustString() - - subject, textBody, htmlBody, err := emails.RenderInvitation( - s.baseURL, - invitation.FullName, + subject, textBody, htmlBody, err := emailPresenter.RenderInvitation( + "/auth/signup-from-invitation", + invitationToken, organization.Name, - invitationURL, ) if err != nil { return fmt.Errorf("cannot render invitation email: %w", err) diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 7644ba768..3ae13e40d 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -41,14 +41,15 @@ type ( privateKey *rsa.PrivateKey logger *log.Logger - AccountService *AccountService - OrganizationService *OrganizationService - SessionService *SessionService - AuthService *AuthService - SAMLService *saml.Service - SCIMService *scim.Service - APIKeyService *APIKeyService - Authorizer *Authorizer + AccountService *AccountService + OrganizationService *OrganizationService + CompliancePageService *CompliancePageService + SessionService *SessionService + AuthService *AuthService + SAMLService *saml.Service + SCIMService *scim.Service + APIKeyService *APIKeyService + Authorizer *Authorizer samlDomainVerifier *SAMLDomainVerifier } @@ -118,6 +119,7 @@ func NewService( svc.AccountService = NewAccountService(svc) svc.OrganizationService = NewOrganizationService(svc) + svc.CompliancePageService = NewCompliancePageService(svc) svc.SessionService = NewSessionService(svc) svc.AuthService = NewAuthService(svc) svc.APIKeyService = NewAPIKeyService(svc) diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 6de34d97d..25d1c1e1a 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "net/url" "os" "regexp" "strings" @@ -544,25 +543,12 @@ func (s *DocumentService) SendSigningNotifications( return fmt.Errorf("cannot create signing request token: %w", err) } - baseURLParsed, err := url.Parse(s.svc.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } + emailPresenter := emails.NewPresenter(s.svc.baseURL, people.FullName) - signRequestURL := url.URL{ - Scheme: baseURLParsed.Scheme, - Host: baseURLParsed.Host, - Path: "/documents/signing-requests", - RawQuery: url.Values{ - "token": []string{token}, - }.Encode(), - } - - subject, textBody, htmlBody, err := emails.RenderDocumentSigning( - s.svc.baseURL, - people.FullName, + subject, textBody, htmlBody, err := emailPresenter.RenderDocumentSigning( + "/documents/signing-requests", + token, organization.Name, - signRequestURL.String(), ) if err != nil { return fmt.Errorf("cannot render signing request email: %w", err) @@ -1837,9 +1823,9 @@ func (s *DocumentService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - subject, textBody, htmlBody, err := emails.RenderDocumentExport( - s.svc.baseURL, - recipientName, + emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName) + + subject, textBody, htmlBody, err := emailPresenter.RenderDocumentExport( downloadURL, ) if err != nil { diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 37e2b2206..eeda001a3 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -800,9 +800,9 @@ func (s FrameworkService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - subject, textBody, htmlBody, err := emails.RenderFrameworkExport( - s.svc.baseURL, - recipientName, + emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName) + + subject, textBody, htmlBody, err := emailPresenter.RenderFrameworkExport( downloadURL, ) if err != nil { diff --git a/pkg/probo/trust_center_access_service.go b/pkg/probo/trust_center_access_service.go index 49ced4ad4..1e983eb33 100644 --- a/pkg/probo/trust_center_access_service.go +++ b/pkg/probo/trust_center_access_service.go @@ -18,7 +18,6 @@ import ( "context" "errors" "fmt" - "net/url" "time" "go.gearno.de/kit/pg" @@ -412,36 +411,6 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con return fmt.Errorf("cannot load organization: %w", err) } - baseURLParsed, err := url.Parse(s.svc.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } - - hostname := baseURLParsed.Host - scheme := baseURLParsed.Scheme - path := "/trust/" + trustCenter.Slug - - if organization.CustomDomainID != nil { - customDomain, err := s.svc.CustomDomains.GetOrganizationCustomDomain(ctx, organization.ID) - if err != nil { - return fmt.Errorf("cannot load custom domain: %w", err) - } - - if customDomain == nil || customDomain.SSLStatus != coredata.CustomDomainSSLStatusActive { - return fmt.Errorf("custom domain is not active") - } - - hostname = customDomain.Domain - scheme = "https" - path = "" - } - - accessURL := url.URL{ - Scheme: scheme, - Host: hostname, - Path: path, - } - now := time.Now() access.UpdatedAt = now @@ -449,30 +418,21 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con return fmt.Errorf("cannot update trust center access with expiration: %w", err) } - return s.sendTrustCenterAccessEmail(ctx, tx, access.Name, access.Email, organization.Name, accessURL.String()) -} + emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID) + if err != nil { + return fmt.Errorf("cannot get compliance page email presenter config: %w", err) + } -func (s TrustCenterAccessService) sendTrustCenterAccessEmail( - ctx context.Context, - tx pg.Conn, - name string, - email mail.Addr, - companyName string, - accessURL string, -) error { - subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess( - s.svc.baseURL, - name, - companyName, - accessURL, - ) + emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, access.Name) + + subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(organization.Name) if err != nil { return fmt.Errorf("cannot render trust center access email: %w", err) } accessEmail := coredata.NewEmail( - name, - email, + access.Name, + access.Email, subject, textBody, htmlBody, diff --git a/pkg/probo/trust_center_service.go b/pkg/probo/trust_center_service.go index 561d8c803..3501f751a 100644 --- a/pkg/probo/trust_center_service.go +++ b/pkg/probo/trust_center_service.go @@ -16,9 +16,11 @@ package probo import ( "context" + "errors" "fmt" "io" "mime" + "net/url" "path/filepath" "time" @@ -26,6 +28,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" + "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/validator" @@ -568,3 +571,90 @@ func (s TrustCenterService) GenerateDarkLogoURL( return &presignedURL, nil } + +func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, compliancePageID gid.GID) (emails.PresenterConfig, error) { + var ( + compliancePage = &coredata.TrustCenter{} + organization = &coredata.Organization{} + customDomain *coredata.CustomDomain + logoFile = &coredata.File{} + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL) + ) + + scope := coredata.NewScopeFromObjectID(compliancePageID) + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil { + return fmt.Errorf("cannot load compliance page: %w", err) + } + + if compliancePage.LogoFileID != nil { + if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil { + return fmt.Errorf("cannot load logoFile: %w", err) + } + } + + if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + customDomain = &coredata.CustomDomain{} + if err := customDomain.LoadByOrganizationID(ctx, conn, scope, s.svc.encryptionKey, organization.ID); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load custom domain: %w", err) + } + } + + return nil + }, + ) + if err != nil { + return emailPresenterCfg, err + } + + parsedBaseURL, err := url.Parse(s.svc.baseURL) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot parse base URL: %w", err) + } + + baseURL := url.URL{ + Scheme: parsedBaseURL.Scheme, + Host: parsedBaseURL.Host, + Path: "/trust/" + compliancePage.Slug, + } + + if customDomain != nil && customDomain.SSLStatus == coredata.CustomDomainSSLStatusActive { + baseURL.Host = customDomain.Domain + baseURL.Scheme = "https" + baseURL.Path = "" + } + + emailPresenterCfg.BaseURL = baseURL.String() + + if compliancePage.LogoFileID != nil { + if logoFile.FileKey == "" { + return emailPresenterCfg, nil + } + + presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, logoFile, 1*time.Hour) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + } + + emailPresenterCfg.SenderCompanyLogoURL = presignedURL + + emailPresenterCfg.SenderCompanyName = organization.Name + + if organization.WebsiteURL != nil { + emailPresenterCfg.SenderCompanyWebsiteURL = *organization.WebsiteURL + } + + if organization.HeadquarterAddress != nil { + emailPresenterCfg.SenderCompanyHeadquarterAddress = *organization.HeadquarterAddress + } + } + + return emailPresenterCfg, nil +} diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index e8c3eb493..90d3bab25 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -13,7 +13,6 @@ import ( "time" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" @@ -161,39 +160,11 @@ func (r *mutationResolver) SendMagicLink(ctx context.Context, input types.SendMa trustCenter := compliancepage.CompliancePageFromContext(ctx) - organization, err := r.iam.OrganizationService.GetOrganization(ctx, trustCenter.OrganizationID) - if err != nil { - var errNotFound *iam.ErrOrganizationNotFound - if errors.As(err, &errNotFound) { - return nil, gqlutils.NotFoundf(ctx, "organization not found") - } - - r.logger.ErrorCtx(ctx, "cannot get organization", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - customDomain, err := r.trust.GetCustomDomainByOrganizationID(ctx, organization.ID) - if err != nil && !errors.Is(err, trust.ErrCustomDomainNotFound) { - r.logger.ErrorCtx(ctx, "cannot get custom domain", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - req := &iam.SendMagicLinkRequest{ - Email: input.Email, - } - - if customDomain != nil { - baseURL, err := baseurl.Parse(fmt.Sprintf("https://%s", customDomain.Domain)) - if err != nil { - r.logger.ErrorCtx(ctx, "cannot parse custom domain url", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - req.BaseURL = baseURL.WithPath("/verify-magic-link") - } else { - req.BaseURL = r.baseURL.WithPath( - fmt.Sprintf("/trust/%s/verify-magic-link", trustCenter.ID.String()), - ) + Email: input.Email, + CompliancePageID: &trustCenter.ID, + OrganizationID: trustCenter.OrganizationID, + URLPath: "verify-magic-link", } if err := r.iam.AuthService.SendMagicLink(ctx, req); err != nil { diff --git a/pkg/trust/trust_center_access_service.go b/pkg/trust/trust_center_access_service.go index dc7bee22d..04a9e5ee1 100644 --- a/pkg/trust/trust_center_access_service.go +++ b/pkg/trust/trust_center_access_service.go @@ -19,7 +19,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "time" "go.gearno.de/kit/log" @@ -478,36 +477,6 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co return fmt.Errorf("cannot load organization: %w", err) } - baseURLParsed, err := url.Parse(s.svc.baseURL) - if err != nil { - return fmt.Errorf("cannot parse base URL: %w", err) - } - - hostname := baseURLParsed.Host - scheme := baseURLParsed.Scheme - path := "/trust/" + trustCenter.Slug - - if organization.CustomDomainID != nil { - customDomain, err := s.svc.Organizations.GetOrganizationCustomDomain(ctx, organization.ID) - if err != nil { - return fmt.Errorf("cannot load custom domain: %w", err) - } - - if customDomain == nil || customDomain.SSLStatus != coredata.CustomDomainSSLStatusActive { - return fmt.Errorf("custom domain is not active") - } - - hostname = customDomain.Domain - scheme = "https" - path = "" - } - - accessURL := url.URL{ - Scheme: scheme, - Host: hostname, - Path: path, - } - now := time.Now() access.UpdatedAt = now @@ -515,30 +484,21 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co return fmt.Errorf("cannot update trust center access with expiration: %w", err) } - return s.sendTrustCenterAccessEmail(ctx, tx, access.Name, access.Email, organization.Name, accessURL.String()) -} + emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID) + if err != nil { + return fmt.Errorf("cannot get compliance page email presenter config: %w", err) + } -func (s *TrustCenterAccessService) sendTrustCenterAccessEmail( - ctx context.Context, - tx pg.Conn, - name string, - email mail.Addr, - companyName string, - accessURL string, -) error { - subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess( - s.svc.baseURL, - name, - companyName, - accessURL, - ) + emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, access.Name) + + subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(organization.Name) if err != nil { return fmt.Errorf("cannot render trust center access email: %w", err) } accessEmail := coredata.NewEmail( - name, - email, + access.Name, + access.Email, subject, textBody, htmlBody, @@ -648,11 +608,16 @@ func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail( } } - subject, textBody, htmlBody, err := emails.RenderTrustCenterDocumentAccessRejected( - s.svc.baseURL, - access.Name, - organization.Name, + emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID) + if err != nil { + return fmt.Errorf("cannot get compliance page email presenter config: %w", err) + } + + emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, access.Name) + + subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterDocumentAccessRejected( fileNames, + organization.Name, ) if err != nil { return fmt.Errorf("cannot render trust center documents access rejected email: %w", err) diff --git a/pkg/trust/trust_center_service.go b/pkg/trust/trust_center_service.go index e421335dc..68a6dcebc 100644 --- a/pkg/trust/trust_center_service.go +++ b/pkg/trust/trust_center_service.go @@ -16,10 +16,13 @@ package trust import ( "context" + "errors" "fmt" + "net/url" "time" "go.gearno.de/kit/pg" + "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" ) @@ -216,3 +219,92 @@ func (s TrustCenterService) GenerateDarkLogoURL( return &presignedURL, nil } + +func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, compliancePageID gid.GID) (emails.PresenterConfig, error) { + var ( + compliancePage = &coredata.TrustCenter{} + organization = &coredata.Organization{} + customDomain *coredata.CustomDomain + logoFile = &coredata.File{} + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL) + ) + + scope := coredata.NewScopeFromObjectID(compliancePageID) + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil { + return fmt.Errorf("cannot load compliance page: %w", err) + } + + if compliancePage.LogoFileID != nil { + if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil { + return fmt.Errorf("cannot load logoFile: %w", err) + } + } + + if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + customDomain = &coredata.CustomDomain{} + if err := customDomain.LoadByOrganizationID(ctx, conn, scope, s.svc.encryptionKey, organization.ID); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load custom domain: %w", err) + } + } + + return nil + }, + ) + if err != nil { + return emailPresenterCfg, err + } + + parsedBaseURL, err := url.Parse(s.svc.baseURL) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot parse base URL: %w", err) + } + + baseURL := url.URL{ + Scheme: parsedBaseURL.Scheme, + Host: parsedBaseURL.Host, + Path: "/trust/" + compliancePage.Slug, + } + + if customDomain != nil && customDomain.SSLStatus == coredata.CustomDomainSSLStatusActive { + baseURL.Host = customDomain.Domain + baseURL.Scheme = "https" + baseURL.Path = "" + } + + emailPresenterCfg.BaseURL = baseURL.String() + + if compliancePage.LogoFileID != nil { + if logoFile.FileKey == "" { + return emailPresenterCfg, nil + } + + // If logo exists, then we will brand the emails with the org as a sender + + presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, logoFile, 1*time.Hour) + if err != nil { + return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + } + + emailPresenterCfg.SenderCompanyLogoURL = presignedURL + + emailPresenterCfg.SenderCompanyName = organization.Name + + if organization.WebsiteURL != nil { + emailPresenterCfg.SenderCompanyWebsiteURL = *organization.WebsiteURL + } + + if organization.HeadquarterAddress != nil { + emailPresenterCfg.SenderCompanyHeadquarterAddress = *organization.HeadquarterAddress + } + } + + return emailPresenterCfg, nil +}