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

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

View File

@@ -10,11 +10,11 @@ import EmailLayout, {
export const MagicLink = () => {
return (
<EmailLayout subject="Probo Magic Link">
<Text style={bodyText}>Please use this link to connect to Probo:</Text>
<Text style={bodyText}>{"Please use this link to connect to {{.OrganizationName}}'s Compliance Page:"}</Text>
<Section style={buttonContainer}>
<Button style={button} href={"{{.MagicLinkURL}}"}>
Connect to Probo
Connect
</Button>
</Section>

View File

@@ -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 = ({
<Container style={container}>
<Section style={content}>
<Section style={logoSection}>
<Link href="https://www.getprobo.com">
<ProboLogo />
<Link href="{{.SenderCompanyWebsiteURL}}">
<Logo />
</Link>
</Section>
<Text style={text}>Hi {'{{.FullName}}'},</Text>
<Text style={text}>Hi {'{{.RecipientFullName}}'},</Text>
{children}
</Section>
<Section style={footerSection}>
<Text style={footerAddress}>
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
{"{{.SenderCompanyHeadquarterAddress}}"}
</Text>
</Section>
</Container>

View File

@@ -0,0 +1,13 @@
import { Img } from '@react-email/components';
import * as React from 'react';
export function Logo() {
return (
<Img
className="max-width-[220px]"
src="{{.SenderCompanyLogoURL}}"
alt="{{.SenderCompanyName}}"
height="60"
/>
);
}

View File

@@ -1,13 +0,0 @@
import { Img } from '@react-email/components';
import * as React from 'react';
export function ProboLogo() {
return (
<Img
src={'{{.LogoURL}}'}
alt="Probo"
width="220"
height="60"
/>
);
}

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
}