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)