Extract email presentation configuration layer to streamline email branding
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
packages/emails/src/components/Logo.tsx
Normal file
13
packages/emails/src/components/Logo.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
175
pkg/iam/compliance_page_service.go
Normal file
175
pkg/iam/compliance_page_service.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user