diff --git a/apps/console/public/logos/probo-gray-small.png b/packages/emails/assets/probo-gray-small.png similarity index 100% rename from apps/console/public/logos/probo-gray-small.png rename to packages/emails/assets/probo-gray-small.png diff --git a/packages/emails/assets/probo.png b/packages/emails/assets/probo.png new file mode 100644 index 000000000..6ef651e42 Binary files /dev/null and b/packages/emails/assets/probo.png differ diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 135e8280f..b9a7d7463 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -16,22 +16,46 @@ package emails import ( "bytes" + "context" "embed" + "errors" "fmt" htmltemplate "html/template" + "io/fs" + "mime" "net/url" + "path/filepath" texttemplate "text/template" "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/filevalidation" ) //go:embed dist var Templates embed.FS +var ( + //go:embed assets + staticAssets embed.FS + + staticAssetsValidator = filevalidation.NewValidator( + filevalidation.WithMaxFileSize(5*1024*1024), + filevalidation.WithCategories( + filevalidation.CategoryImage, + filevalidation.CategoryVideo, + ), + ) +) + +type StaticAssetURLs map[string]string + type ( PresenterConfig struct { BaseURL string + PoweredByLogoURL string SenderCompanyName string SenderCompanyWebsiteURL string SenderCompanyLogoURL string @@ -40,8 +64,8 @@ type ( PresenterVariables struct { // Static variables - BaseOrigin string BaseURL string + PoweredByLogoURL string SenderCompanyName string SenderCompanyWebsiteURL string SenderCompanyLogoURL string @@ -58,27 +82,22 @@ type ( } ) -func DefaultPresenterConfig(baseURL string) PresenterConfig { +func DefaultPresenterConfig(baseURL string, staticAssetURLs StaticAssetURLs) PresenterConfig { return PresenterConfig{ BaseURL: baseURL, + PoweredByLogoURL: staticAssetURLs["probo-gray-small.png"], SenderCompanyName: "Probo", SenderCompanyWebsiteURL: "https://www.getprobo.com", - SenderCompanyLogoURL: baseurl.MustParse(baseURL).AppendPath("/logos/probo.png").MustString(), + SenderCompanyLogoURL: staticAssetURLs["probo.png"], SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US", } } func NewPresenterFromConfig(cfg PresenterConfig, fullName string) *Presenter { - baseURL := baseurl.MustParse(cfg.BaseURL) - baseOrigin := url.URL{ - Scheme: baseURL.Scheme(), - Host: baseURL.Host(), - } - return &Presenter{ variables: PresenterVariables{ - BaseOrigin: baseOrigin.String(), BaseURL: cfg.BaseURL, + PoweredByLogoURL: cfg.PoweredByLogoURL, SenderCompanyName: cfg.SenderCompanyName, SenderCompanyWebsiteURL: cfg.SenderCompanyWebsiteURL, SenderCompanyLogoURL: cfg.SenderCompanyLogoURL, @@ -89,13 +108,102 @@ func NewPresenterFromConfig(cfg PresenterConfig, fullName string) *Presenter { } } -func NewPresenter(baseURL string, fullName string) *Presenter { +func NewPresenter(baseURL string, staticAssetURLs StaticAssetURLs, fullName string) *Presenter { return NewPresenterFromConfig( - DefaultPresenterConfig(baseURL), + DefaultPresenterConfig(baseURL, staticAssetURLs), fullName, ) } +func GenerateStaticAssetURLs(ctx context.Context, s3Client *s3.Client, bucket string) (StaticAssetURLs, error) { + assetURLs := make(map[string]string) + + subFS, err := fs.Sub(staticAssets, "assets") + if err != nil { + return nil, fmt.Errorf("cannot create subtree file system: %w", err) + } + + err = fs.WalkDir(subFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + + return fmt.Errorf("cannot get dir entry info: %w", err) + } + + ext := filepath.Ext(info.Name()) + mimeType := mime.TypeByExtension(ext) + + if err := staticAssetsValidator.Validate(info.Name(), mimeType, info.Size()); err != nil { + return fmt.Errorf("cannot validate file: %w", err) + } + + file, err := subFS.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + + _, err = s3Client.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(path), + Body: file, + Metadata: map[string]string{ + "type": "static-email-asset", + }, + ContentType: aws.String(mimeType), + }, + ) + if err != nil { + return fmt.Errorf("cannot upload file to S3: %w", err) + } + + presignClient := s3.NewPresignClient(s3Client) + + encodedFilename := url.QueryEscape(info.Name()) + contentDisposition := fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", + encodedFilename, encodedFilename) + + presignedReq, err := presignClient.PresignGetObject( + ctx, + &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(path), + ResponseCacheControl: aws.String("max-age=3600, public"), + ResponseContentDisposition: &contentDisposition, + }, + func(opts *s3.PresignOptions) { + opts.Expires = 7 * 24 * time.Hour + }, + ) + if err != nil { + return fmt.Errorf("cannot presign GetObject request: %w", err) + } + + assetURLs[path] = presignedReq.URL + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("cannot generate asset URLs: %w", err) + } + + return assetURLs, nil +} + const ( subjectConfirmEmail = "Confirm your email address" subjectPasswordReset = "Reset your password" diff --git a/packages/emails/src/components/ProboLogo.tsx b/packages/emails/src/components/ProboLogo.tsx index ac66ff9f1..804b62af7 100644 --- a/packages/emails/src/components/ProboLogo.tsx +++ b/packages/emails/src/components/ProboLogo.tsx @@ -5,7 +5,7 @@ export function ProboLogo() { return ( Probo diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 6d6d923e4..26de2f85f 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -116,7 +116,7 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req return fmt.Errorf("cannot update identity: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) + emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) if err != nil { diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 102e6e7be..bddf0ea6f 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -290,7 +290,7 @@ func (s AuthService) SendPasswordResetInstructionByEmail( return fmt.Errorf("cannot load identity: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) + emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderPasswordReset( "/auth/reset-password", @@ -406,7 +406,7 @@ func (s AuthService) CreateIdentityWithPassword( return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, req.FullName) + emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, req.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) if err != nil { @@ -569,7 +569,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques return fmt.Errorf("cannot load organization: %w", err) } - emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL) + emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL, s.emailStaticAssetURLs) if req.CompliancePageID != nil { var err error emailPresenterCfg, err = s.CompliancePageService.EmailPresenterConfig(ctx, *req.CompliancePageID) diff --git a/pkg/iam/compliance_page_service.go b/pkg/iam/compliance_page_service.go index 8c533aaf2..f56e7fb3f 100644 --- a/pkg/iam/compliance_page_service.go +++ b/pkg/iam/compliance_page_service.go @@ -91,7 +91,7 @@ func (s *CompliancePageService) EmailPresenterConfig(ctx context.Context, compli organization = &coredata.Organization{} customDomain *coredata.CustomDomain logoFile = &coredata.File{} - emailPresenterCfg = emails.DefaultPresenterConfig(s.baseURL) + emailPresenterCfg = emails.DefaultPresenterConfig(s.baseURL, s.emailStaticAssetURLs) ) scope := coredata.NewScopeFromObjectID(compliancePageID) diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 5f7789bdf..e3c43603a 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -464,7 +464,7 @@ func (s *OrganizationService) InviteMember( return fmt.Errorf("cannot generate invitation token: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName) + emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderInvitation( "/auth/signup-from-invitation", diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 3ae13e40d..e237e38ff 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -11,6 +11,7 @@ import ( "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.opentelemetry.io/otel/trace" + "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" @@ -28,6 +29,7 @@ type ( pg *pg.Client fm *filemanager.Service hp *passwdhash.Profile + emailStaticAssetURLs emails.StaticAssetURLs encryptionKey cipher.EncryptionKey baseURL string tokenSecret string @@ -82,6 +84,7 @@ func NewService( pgClient *pg.Client, fm *filemanager.Service, hp *passwdhash.Profile, + emailStaticAssetURLs emails.StaticAssetURLs, cfg Config, ) (*Service, error) { if cfg.Bucket == "" { @@ -104,6 +107,7 @@ func NewService( pg: pgClient, fm: fm, hp: hp, + emailStaticAssetURLs: emailStaticAssetURLs, baseURL: cfg.BaseURL.String(), tokenSecret: cfg.TokenSecret, disableSignup: cfg.DisableSignup, diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 533c89e0f..683296db9 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -543,7 +543,7 @@ func (s *DocumentService) SendSigningNotifications( return fmt.Errorf("cannot create signing request token: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, people.FullName) + emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, people.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderDocumentSigning( "/documents/signing-requests", @@ -1823,7 +1823,7 @@ func (s *DocumentService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName) + emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, recipientName) subject, textBody, htmlBody, err := emailPresenter.RenderDocumentExport( downloadURL, diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 03691fbe5..e53402a21 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -254,17 +254,17 @@ func (s FrameworkService) Export( return fmt.Errorf("cannot load evidence file: %w", err) } - object, err := s.svc.s3.GetObject( - ctx, - &s3.GetObjectInput{ - Bucket: aws.String(s.svc.bucket), - Key: aws.String(evidence_file.FileKey), - }, - ) - if err != nil { - return fmt.Errorf("cannot download evidence: %w", err) - } - defer func() { _ = object.Body.Close() }() + object, err := s.svc.s3.GetObject( + ctx, + &s3.GetObjectInput{ + Bucket: aws.String(s.svc.bucket), + Key: aws.String(evidence_file.FileKey), + }, + ) + if err != nil { + return fmt.Errorf("cannot download evidence: %w", err) + } + defer func() { _ = object.Body.Close() }() w, err := archive.Create(fmt.Sprintf("%s/%s/%s/%s", framework.Name, control.SectionTitle, measure.Name, evidence_file.FileName)) if err != nil { @@ -800,7 +800,7 @@ func (s FrameworkService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName) + emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, recipientName) subject, textBody, htmlBody, err := emailPresenter.RenderFrameworkExport( downloadURL, diff --git a/pkg/probo/service.go b/pkg/probo/service.go index dae5acb12..77c6df0d0 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -23,6 +23,7 @@ import ( "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.gearno.de/x/ref" + "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/agents" "go.probo.inc/probo/pkg/certmanager" "go.probo.inc/probo/pkg/coredata" @@ -49,18 +50,19 @@ type ExportService interface { type ( Service struct { - pg *pg.Client - s3 *s3.Client - bucket string - encryptionKey cipher.EncryptionKey - baseURL string - tokenSecret string - agentConfig agents.Config - html2pdfConverter *html2pdf.Converter - acmeService *certmanager.ACMEService - fileManager *filemanager.Service - logger *log.Logger - slack *slack.Service + pg *pg.Client + s3 *s3.Client + bucket string + encryptionKey cipher.EncryptionKey + baseURL string + tokenSecret string + agentConfig agents.Config + html2pdfConverter *html2pdf.Converter + acmeService *certmanager.ACMEService + fileManager *filemanager.Service + logger *log.Logger + slack *slack.Service + emailStaticAssetURLs emails.StaticAssetURLs } TenantService struct { @@ -73,6 +75,7 @@ type ( tokenSecret string agent *agents.Agent fileManager *filemanager.Service + emailStaticAssetURLs emails.StaticAssetURLs Frameworks *FrameworkService Measures *MeasureService Tasks *TaskService @@ -106,7 +109,7 @@ type ( ProcessingActivities *ProcessingActivityService DataProtectionImpactAssessments *DataProtectionImpactAssessmentService TransferImpactAssessments *TransferImpactAssessmentService - StatesOfApplicability *StateOfApplicabilityService + StatesOfApplicability *StateOfApplicabilityService Files *FileService CustomDomains *CustomDomainService SlackMessages *slack.SlackMessageService @@ -128,6 +131,7 @@ func NewService( logger *log.Logger, slackService *slack.Service, iamService *iam.Service, + emailStaticAssetURLs emails.StaticAssetURLs, ) (*Service, error) { if bucket == "" { return nil, fmt.Errorf("bucket is required") @@ -136,18 +140,19 @@ func NewService( iamService.Authorizer.RegisterPolicySet(ProboPolicySet()) svc := &Service{ - pg: pgClient, - s3: s3Client, - bucket: bucket, - encryptionKey: encryptionKey, - baseURL: baseURL, - tokenSecret: tokenSecret, - agentConfig: agentConfig, - html2pdfConverter: html2pdfConverter, - acmeService: acmeService, - fileManager: fileManagerService, - logger: logger, - slack: slackService, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + baseURL: baseURL, + tokenSecret: tokenSecret, + agentConfig: agentConfig, + html2pdfConverter: html2pdfConverter, + acmeService: acmeService, + fileManager: fileManagerService, + logger: logger, + slack: slackService, + emailStaticAssetURLs: emailStaticAssetURLs, } return svc, nil @@ -155,15 +160,16 @@ func NewService( func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService := &TenantService{ - pg: s.pg, - s3: s.s3, - bucket: s.bucket, - encryptionKey: s.encryptionKey, - baseURL: s.baseURL, - scope: coredata.NewScope(tenantID), - tokenSecret: s.tokenSecret, - agent: agents.NewAgent(nil, s.agentConfig), - fileManager: s.fileManager, + pg: s.pg, + s3: s.s3, + bucket: s.bucket, + encryptionKey: s.encryptionKey, + baseURL: s.baseURL, + scope: coredata.NewScope(tenantID), + tokenSecret: s.tokenSecret, + agent: agents.NewAgent(nil, s.agentConfig), + fileManager: s.fileManager, + emailStaticAssetURLs: s.emailStaticAssetURLs, } tenantService.Frameworks = &FrameworkService{ diff --git a/pkg/probo/trust_center_service.go b/pkg/probo/trust_center_service.go index 8fbbbaec2..bf8e17f8b 100644 --- a/pkg/probo/trust_center_service.go +++ b/pkg/probo/trust_center_service.go @@ -609,7 +609,7 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, complianc organization = &coredata.Organization{} customDomain *coredata.CustomDomain logoFile = &coredata.File{} - emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL) + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL, s.svc.emailStaticAssetURLs) ) scope := coredata.NewScopeFromObjectID(compliancePageID) diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 46b97b3cd..563557f38 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -29,6 +29,7 @@ import ( "sync" "time" + "go.probo.inc/probo/packages/emails" pemutil "go.probo.inc/probo/pkg/crypto/pem" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -292,11 +293,21 @@ func (impl *Implm) Run( } } + emailStaticAssetURLs, err := emails.GenerateStaticAssetURLs( + ctx, + s3Client, + impl.cfg.AWS.Bucket, + ) + if err != nil { + return fmt.Errorf("cannot generate email static asset URLs: %w", err) + } + iamService, err := iam.NewService( ctx, pgClient, fileManagerService, hp, + emailStaticAssetURLs, iam.Config{ DisableSignup: impl.cfg.Auth.DisableSignup, InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second, @@ -376,6 +387,7 @@ func (impl *Implm) Run( l.Named("probo"), slackService, iamService, + emailStaticAssetURLs, ) if err != nil { return fmt.Errorf("cannot create probo service: %w", err) @@ -393,6 +405,7 @@ func (impl *Implm) Run( fileManagerService, l, slackService, + emailStaticAssetURLs, ) serverHandler, err := server.NewServer( diff --git a/pkg/trust/service.go b/pkg/trust/service.go index ab357b097..1b11ddfa7 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -22,6 +22,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" + "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/filemanager" @@ -35,18 +36,19 @@ import ( type ( Service struct { - pg *pg.Client - s3 *s3.Client - bucket string - proboSvc *probo.Service - encryptionKey cipher.EncryptionKey - slackSigningSecret string - baseURL string - iam *iam.Service - html2pdfConverter *html2pdf.Converter - fileManager *filemanager.Service - logger *log.Logger - slack *slack.Service + pg *pg.Client + s3 *s3.Client + bucket string + proboSvc *probo.Service + encryptionKey cipher.EncryptionKey + slackSigningSecret string + baseURL string + iam *iam.Service + html2pdfConverter *html2pdf.Converter + fileManager *filemanager.Service + logger *log.Logger + slack *slack.Service + emailStaticAssetURLs emails.StaticAssetURLs } TenantService struct { @@ -61,6 +63,7 @@ type ( html2pdfConverter *html2pdf.Converter fileManager *filemanager.Service logger *log.Logger + emailStaticAssetURLs emails.StaticAssetURLs TrustCenters *TrustCenterService Documents *DocumentService Audits *AuditService @@ -87,35 +90,38 @@ func NewService( fileManagerService *filemanager.Service, logger *log.Logger, slack *slack.Service, + emailStaticAssetURLs emails.StaticAssetURLs, ) *Service { return &Service{ - pg: pgClient, - s3: s3Client, - bucket: bucket, - encryptionKey: encryptionKey, - slackSigningSecret: slackSigningSecret, - baseURL: baseURL, - iam: iam, - html2pdfConverter: html2pdfConverter, - fileManager: fileManagerService, - logger: logger, - slack: slack, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + slackSigningSecret: slackSigningSecret, + baseURL: baseURL, + iam: iam, + html2pdfConverter: html2pdfConverter, + fileManager: fileManagerService, + logger: logger, + slack: slack, + emailStaticAssetURLs: emailStaticAssetURLs, } } func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService := &TenantService{ - pg: s.pg, - s3: s.s3, - bucket: s.bucket, - scope: coredata.NewScope(tenantID), - proboSvc: s.proboSvc, - encryptionKey: s.encryptionKey, - baseURL: s.baseURL, - iam: s.iam, - html2pdfConverter: s.html2pdfConverter, - fileManager: s.fileManager, - logger: s.logger, + pg: s.pg, + s3: s.s3, + bucket: s.bucket, + scope: coredata.NewScope(tenantID), + proboSvc: s.proboSvc, + encryptionKey: s.encryptionKey, + baseURL: s.baseURL, + iam: s.iam, + html2pdfConverter: s.html2pdfConverter, + fileManager: s.fileManager, + logger: s.logger, + emailStaticAssetURLs: s.emailStaticAssetURLs, } tenantService.TrustCenters = &TrustCenterService{svc: tenantService} diff --git a/pkg/trust/trust_center_service.go b/pkg/trust/trust_center_service.go index 16efbd65b..5560f135d 100644 --- a/pkg/trust/trust_center_service.go +++ b/pkg/trust/trust_center_service.go @@ -226,7 +226,7 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, complianc organization = &coredata.Organization{} customDomain *coredata.CustomDomain logoFile = &coredata.File{} - emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL) + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL, s.svc.emailStaticAssetURLs) ) scope := coredata.NewScopeFromObjectID(compliancePageID)