Extract email presentation configuration layer to streamline email branding

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-01-30 17:31:26 +04:00
parent b18ebe96f8
commit e13ff134ed
17 changed files with 600 additions and 331 deletions

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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