diff --git a/go.sum b/go.sum index 020f3959d..ac25b4073 100644 --- a/go.sum +++ b/go.sum @@ -263,8 +263,6 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg= -go.gearno.de/kit v0.1.0 h1:2fNvGoPTHBUH05lAFt8SAYlhhPPbFyBv3ED8W1bK39o= -go.gearno.de/kit v0.1.0/go.mod h1:WI/gQ14O9M6wsKa/HFL4ZH+Q/U0930hYWZRdHHo9Agk= go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE= go.gearno.de/kit v0.1.1/go.mod h1:WI/gQ14O9M6wsKa/HFL4ZH+Q/U0930hYWZRdHHo9Agk= go.gearno.de/x/panicf v0.1.1 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ= diff --git a/packages/emails/emails.go b/packages/emails/emails.go index b9a7d7463..95150e783 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -23,7 +23,6 @@ import ( htmltemplate "html/template" "io/fs" "mime" - "net/url" "path/filepath" texttemplate "text/template" "time" @@ -31,6 +30,7 @@ import ( "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/filemanager" "go.probo.inc/probo/pkg/filevalidation" ) @@ -48,21 +48,28 @@ var ( filevalidation.CategoryVideo, ), ) + + staticAssetsDuration = 7 * 24 * time.Hour ) -type StaticAssetURLs map[string]string - type ( + Asset struct { + Name string + ObjectKey string + BucketName string + MimeType string + } + PresenterConfig struct { BaseURL string - PoweredByLogoURL string + PoweredByLogo Asset SenderCompanyName string SenderCompanyWebsiteURL string - SenderCompanyLogoURL string + SenderCompanyLogo Asset SenderCompanyHeadquarterAddress string } - PresenterVariables struct { + CommonVariables struct { // Static variables BaseURL string PoweredByLogoURL string @@ -73,54 +80,74 @@ type ( // 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 + fm *filemanager.Service + config PresenterConfig + RecipientFullName string } ) -func DefaultPresenterConfig(baseURL string, staticAssetURLs StaticAssetURLs) PresenterConfig { +func (a *Asset) GetObjectKey() string { + return a.ObjectKey +} + +func (a *Asset) GetName() string { + return a.Name +} + +func (a *Asset) GetBucketName() string { + return a.BucketName +} + +func (a *Asset) GetMimeType() string { + return a.MimeType +} + +var _ filemanager.File = (*Asset)(nil) + +func DefaultPresenterConfig(bucketName string, baseURL string) PresenterConfig { return PresenterConfig{ - BaseURL: baseURL, - PoweredByLogoURL: staticAssetURLs["probo-gray-small.png"], - SenderCompanyName: "Probo", - SenderCompanyWebsiteURL: "https://www.getprobo.com", - SenderCompanyLogoURL: staticAssetURLs["probo.png"], + BaseURL: baseURL, + PoweredByLogo: Asset{ + Name: "probo-gray-small.png", + ObjectKey: "probo-gray-small.png", + BucketName: bucketName, + MimeType: "image/png", + }, + SenderCompanyName: "Probo", + SenderCompanyWebsiteURL: "https://www.getprobo.com", + SenderCompanyLogo: Asset{ + Name: "probo.png", + ObjectKey: "probo.png", + BucketName: bucketName, + MimeType: "image/png", + }, SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US", } } -func NewPresenterFromConfig(cfg PresenterConfig, fullName string) *Presenter { +func NewPresenterFromConfig(fileService *filemanager.Service, cfg PresenterConfig, fullName string) *Presenter { return &Presenter{ - variables: PresenterVariables{ - BaseURL: cfg.BaseURL, - PoweredByLogoURL: cfg.PoweredByLogoURL, - SenderCompanyName: cfg.SenderCompanyName, - SenderCompanyWebsiteURL: cfg.SenderCompanyWebsiteURL, - SenderCompanyLogoURL: cfg.SenderCompanyLogoURL, - SenderCompanyHeadquarterAddress: cfg.SenderCompanyHeadquarterAddress, - - RecipientFullName: fullName, - }, + fm: fileService, + config: cfg, + RecipientFullName: fullName, } } -func NewPresenter(baseURL string, staticAssetURLs StaticAssetURLs, fullName string) *Presenter { +func NewPresenter(fileService *filemanager.Service, bucketName string, baseURL string, fullName string) *Presenter { return NewPresenterFromConfig( - DefaultPresenterConfig(baseURL, staticAssetURLs), + fileService, + DefaultPresenterConfig(bucketName, baseURL), fullName, ) } -func GenerateStaticAssetURLs(ctx context.Context, s3Client *s3.Client, bucket string) (StaticAssetURLs, error) { - assetURLs := make(map[string]string) - +func UpdloadStaticAssets(ctx context.Context, s3Client *s3.Client, bucket string) error { subFS, err := fs.Sub(staticAssets, "assets") if err != nil { - return nil, fmt.Errorf("cannot create subtree file system: %w", err) + return fmt.Errorf("cannot create subtree file system: %w", err) } err = fs.WalkDir(subFS, ".", func(path string, d fs.DirEntry, err error) error { @@ -170,38 +197,14 @@ func GenerateStaticAssetURLs(ctx context.Context, s3Client *s3.Client, bucket st 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 fmt.Errorf("cannot generate asset URLs: %w", err) } - return assetURLs, nil + return nil } const ( @@ -237,119 +240,175 @@ var ( magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl")) ) -func (p *Presenter) RenderConfirmEmail(confirmationURLPath string, confirmationTokenParam string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) { + poweredByLogoURL, err := p.fm.GenerateFileUrl(ctx, &p.config.PoweredByLogo, staticAssetsDuration) + if err != nil { + return nil, fmt.Errorf("cannot generate probo logo URL: %w", err) + } + senderCompanyLogoURL, err := p.fm.GenerateFileUrl(ctx, &p.config.SenderCompanyLogo, staticAssetsDuration) + if err != nil { + return nil, fmt.Errorf("cannot generate sender logo URL: %w", err) + } + + return &CommonVariables{ + BaseURL: p.config.BaseURL, + PoweredByLogoURL: poweredByLogoURL, + SenderCompanyName: p.config.SenderCompanyName, + SenderCompanyWebsiteURL: p.config.SenderCompanyWebsiteURL, + SenderCompanyLogoURL: senderCompanyLogoURL, + SenderCompanyHeadquarterAddress: p.config.SenderCompanyHeadquarterAddress, + RecipientFullName: p.RecipientFullName, + }, nil +} + +func (p *Presenter) RenderConfirmEmail(ctx context.Context, confirmationURLPath string, confirmationTokenParam string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectConfirmEmail, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + confirmationUrl := baseurl. - MustParse(p.variables.BaseURL). + MustParse(vars.BaseURL). AppendPath(confirmationURLPath). WithQuery("token", confirmationTokenParam). MustString() data := struct { - PresenterVariables + *CommonVariables ConfirmationUrl string }{ - PresenterVariables: p.variables, - ConfirmationUrl: confirmationUrl, + CommonVariables: vars, + ConfirmationUrl: confirmationUrl, } textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data) return subjectConfirmEmail, textBody, htmlBody, err } -func (p *Presenter) RenderPasswordReset(resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectConfirmEmail, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + resetUrl := baseurl. - MustParse(p.variables.BaseURL). + MustParse(vars.BaseURL). AppendPath(resetPasswordURLPath). WithQuery("token", resetPasswordToken). MustString() data := struct { - PresenterVariables + *CommonVariables ResetUrl string }{ - PresenterVariables: p.variables, - ResetUrl: resetUrl, + CommonVariables: vars, + ResetUrl: resetUrl, } textBody, htmlBody, err = renderEmail(passwordResetTextTemplate, passwordResetHTMLTemplate, data) return subjectPasswordReset, textBody, htmlBody, err } -func (p *Presenter) RenderInvitation(invitationURLPath string, invitationToken string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath string, invitationToken string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectInvitation, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + invitationURL := baseurl. - MustParse(p.variables.BaseURL). + MustParse(vars.BaseURL). AppendPath(invitationURLPath). WithQuery("token", invitationToken). - WithQuery("fullName", p.variables.RecipientFullName). + WithQuery("fullName", vars.RecipientFullName). MustString() data := struct { - PresenterVariables + *CommonVariables InvitationUrl string OrganizationName string }{ - PresenterVariables: p.variables, - InvitationUrl: invitationURL, - OrganizationName: organizationName, + CommonVariables: vars, + InvitationUrl: invitationURL, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(invitationTextTemplate, invitationHTMLTemplate, data) return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err } -func (p *Presenter) RenderDocumentSigning(signinURLPath string, token string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { - signingURL := baseurl.MustParse(p.variables.BaseURL). +func (p *Presenter) RenderDocumentSigning(ctx context.Context, signinURLPath string, token string, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectDocumentSigning, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + + signingURL := baseurl.MustParse(vars.BaseURL). AppendPath(signinURLPath). WithQuery("token", token). MustString() data := struct { - PresenterVariables + *CommonVariables SigningUrl string OrganizationName string }{ - PresenterVariables: p.variables, - SigningUrl: signingURL, - OrganizationName: organizationName, + CommonVariables: vars, + SigningUrl: signingURL, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data) return fmt.Sprintf(subjectDocumentSigning, organizationName), textBody, htmlBody, err } -func (p *Presenter) RenderDocumentExport(downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderDocumentExport(ctx context.Context, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectDocumentExport, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + data := struct { - PresenterVariables + *CommonVariables DownloadUrl string }{ - PresenterVariables: p.variables, - DownloadUrl: downloadUrl, + CommonVariables: vars, + DownloadUrl: downloadUrl, } textBody, htmlBody, err = renderEmail(documentExportTextTemplate, documentExportHTMLTemplate, data) return subjectDocumentExport, textBody, htmlBody, err } -func (p *Presenter) RenderFrameworkExport(downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderFrameworkExport(ctx context.Context, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectFrameworkExport, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + data := struct { - PresenterVariables + *CommonVariables DownloadUrl string }{ - PresenterVariables: p.variables, - DownloadUrl: downloadUrl, + CommonVariables: vars, + DownloadUrl: downloadUrl, } textBody, htmlBody, err = renderEmail(frameworkExportTextTemplate, frameworkExportHTMLTemplate, data) return subjectFrameworkExport, textBody, htmlBody, err } -func (p *Presenter) RenderTrustCenterAccess(organizationName string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderTrustCenterAccess(ctx context.Context, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectTrustCenterAccess, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + data := struct { - PresenterVariables + *CommonVariables OrganizationName string }{ - PresenterVariables: p.variables, - OrganizationName: organizationName, + CommonVariables: vars, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(trustCenterAccessTextTemplate, trustCenterAccessHTMLTemplate, data) @@ -357,34 +416,45 @@ func (p *Presenter) RenderTrustCenterAccess(organizationName string) (subject st } func (p *Presenter) RenderTrustCenterDocumentAccessRejected( + ctx context.Context, fileNames []string, organizationName string, ) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectTrustCenterDocumentAccessRejected, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + data := struct { - PresenterVariables + *CommonVariables FileNames []string OrganizationName string }{ - PresenterVariables: p.variables, - FileNames: fileNames, - OrganizationName: organizationName, + CommonVariables: vars, + FileNames: fileNames, + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(trustCenterDocumentAccessRejectedTextTemplate, trustCenterDocumentAccessRejectedHTMLTemplate, data) return fmt.Sprintf(subjectTrustCenterDocumentAccessRejected, organizationName), textBody, htmlBody, err } -func (p *Presenter) RenderMagicLink(magicLinkUrlPath string, tokenString string, tokenDuration time.Duration, organizationName string) (subject string, textBody string, htmlBody *string, err error) { +func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string, tokenString string, tokenDuration time.Duration, organizationName string) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables(ctx) + if err != nil { + return subjectMagicLink, textBody, htmlBody, fmt.Errorf("cannot get common variables: %w", err) + } + data := struct { - PresenterVariables + *CommonVariables MagicLinkURL string DurationInMinutes int OrganizationName string }{ - PresenterVariables: p.variables, - MagicLinkURL: baseurl.MustParse(p.variables.BaseURL).AppendPath(magicLinkUrlPath).WithQuery("token", tokenString).MustString(), - DurationInMinutes: int(tokenDuration.Minutes()), - OrganizationName: organizationName, + CommonVariables: vars, + MagicLinkURL: baseurl.MustParse(vars.BaseURL).AppendPath(magicLinkUrlPath).WithQuery("token", tokenString).MustString(), + DurationInMinutes: int(tokenDuration.Minutes()), + OrganizationName: organizationName, } textBody, htmlBody, err = renderEmail(magicLinkTextTemplate, magicLinkHTMLTemplate, data) diff --git a/pkg/coredata/file.go b/pkg/coredata/file.go index 9605582c9..3713dd873 100644 --- a/pkg/coredata/file.go +++ b/pkg/coredata/file.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" ) @@ -44,6 +45,24 @@ type ( Files []*File ) +func (f *File) GetName() string { + return f.FileName +} + +func (f *File) GetObjectKey() string { + return f.FileKey +} + +func (f *File) GetBucketName() string { + return f.BucketName +} + +func (f *File) GetMimeType() string { + return f.MimeType +} + +var _ filemanager.File = (*File)(nil) + // AuthorizationAttributes returns the authorization attributes for policy evaluation. func (f *File) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) { q := `SELECT organization_id FROM files WHERE id = $1 LIMIT 1;` diff --git a/pkg/filemanager/service.go b/pkg/filemanager/service.go index 825f97c63..c09217fd2 100644 --- a/pkg/filemanager/service.go +++ b/pkg/filemanager/service.go @@ -24,12 +24,20 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" - "go.probo.inc/probo/pkg/coredata" ) -type Service struct { - s3Client *s3.Client -} +type ( + Service struct { + s3Client *s3.Client + } + + File interface { + GetObjectKey() string + GetName() string + GetBucketName() string + GetMimeType() string + } +) func NewService(s3Client *s3.Client) *Service { return &Service{ @@ -39,13 +47,13 @@ func NewService(s3Client *s3.Client) *Service { func (s *Service) GetFileBase64( ctx context.Context, - file *coredata.File, + file File, ) (base64Data string, mimeType string, err error) { result, err := s.s3Client.GetObject( ctx, &s3.GetObjectInput{ - Bucket: &file.BucketName, - Key: &file.FileKey, + Bucket: aws.String(file.GetBucketName()), + Key: aws.String(file.GetObjectKey()), }, ) if err != nil { @@ -59,7 +67,7 @@ func (s *Service) GetFileBase64( } if result.ContentType == nil || *result.ContentType == "" { - return "", "", fmt.Errorf("no MIME type available for file %s", file.FileKey) + return "", "", fmt.Errorf("no MIME type available for file %s", file.GetObjectKey()) } base64Data = base64.StdEncoding.EncodeToString(fileData) @@ -89,17 +97,17 @@ func (s *Service) GetFileSize(content io.Reader) (int64, error) { func (s *Service) PutFile( ctx context.Context, - file *coredata.File, + file File, content io.Reader, metadata map[string]string, ) (int64, error) { _, err := s.s3Client.PutObject( ctx, &s3.PutObjectInput{ - Bucket: &file.BucketName, - Key: &file.FileKey, + Bucket: aws.String(file.GetBucketName()), + Key: aws.String(file.GetObjectKey()), Body: content, - ContentType: &file.MimeType, + ContentType: aws.String(file.GetMimeType()), Metadata: metadata, }, ) @@ -110,8 +118,8 @@ func (s *Service) PutFile( headOutput, err := s.s3Client.HeadObject( ctx, &s3.HeadObjectInput{ - Bucket: &file.BucketName, - Key: &file.FileKey, + Bucket: aws.String(file.GetBucketName()), + Key: aws.String(file.GetObjectKey()), }, ) if err != nil { @@ -123,20 +131,20 @@ func (s *Service) PutFile( func (s *Service) GenerateFileUrl( ctx context.Context, - file *coredata.File, + file File, expiresIn time.Duration, ) (string, error) { presignClient := s3.NewPresignClient(s.s3Client) - encodedFilename := url.QueryEscape(file.FileName) + encodedFilename := url.QueryEscape(file.GetName()) contentDisposition := fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", encodedFilename, encodedFilename) presignedReq, err := presignClient.PresignGetObject( ctx, &s3.GetObjectInput{ - Bucket: &file.BucketName, - Key: &file.FileKey, + Bucket: aws.String(file.GetBucketName()), + Key: aws.String(file.GetObjectKey()), ResponseCacheControl: aws.String("max-age=3600, public"), ResponseContentDisposition: &contentDisposition, }, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 26de2f85f..1e47e3de1 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -116,9 +116,9 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req return fmt.Errorf("cannot update identity: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) + emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName) - subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) + subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken) if err != nil { return fmt.Errorf("cannot render confirmation email: %w", err) } diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index bddf0ea6f..2e7807ff4 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -290,9 +290,10 @@ func (s AuthService) SendPasswordResetInstructionByEmail( return fmt.Errorf("cannot load identity: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) + emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderPasswordReset( + ctx, "/auth/reset-password", token, ) @@ -406,9 +407,9 @@ func (s AuthService) CreateIdentityWithPassword( return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, req.FullName) + emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, req.FullName) - subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail("/auth/verify-email", confirmationToken) + subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken) if err != nil { return nil, nil, fmt.Errorf("cannot render confirmation email: %w", err) } @@ -569,7 +570,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques return fmt.Errorf("cannot load organization: %w", err) } - emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL, s.emailStaticAssetURLs) + emailPresenterCfg := emails.DefaultPresenterConfig(s.bucket, s.baseURL) if req.CompliancePageID != nil { var err error emailPresenterCfg, err = s.CompliancePageService.EmailPresenterConfig(ctx, *req.CompliancePageID) @@ -578,9 +579,10 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques } } - emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, fullName) + emailPresenter := emails.NewPresenterFromConfig(s.fm, emailPresenterCfg, fullName) subject, textBody, htmlBody, err := emailPresenter.RenderMagicLink( + ctx, req.URLPath, tokenString, s.magicLinkTokenValidity, diff --git a/pkg/iam/compliance_page_service.go b/pkg/iam/compliance_page_service.go index f56e7fb3f..9af4e6ef2 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, s.emailStaticAssetURLs) + emailPresenterCfg = emails.DefaultPresenterConfig(s.bucket, s.baseURL) ) scope := coredata.NewScopeFromObjectID(compliancePageID) @@ -153,13 +153,13 @@ func (s *CompliancePageService) EmailPresenterConfig(ctx context.Context, compli // If logo exists, then we will brand the emails with the org as a sender - presignedURL, err := s.fm.GenerateFileUrl(ctx, logoFile, 7*24*time.Hour) - if err != nil { - return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + emailPresenterCfg.SenderCompanyLogo = emails.Asset{ + Name: logoFile.FileName, + ObjectKey: logoFile.FileKey, + BucketName: logoFile.BucketName, + MimeType: logoFile.MimeType, } - emailPresenterCfg.SenderCompanyLogoURL = presignedURL - emailPresenterCfg.SenderCompanyName = organization.Name if organization.WebsiteURL != nil { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index e3c43603a..cd7b75049 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -464,9 +464,10 @@ func (s *OrganizationService) InviteMember( return fmt.Errorf("cannot generate invitation token: %w", err) } - emailPresenter := emails.NewPresenter(s.baseURL, s.emailStaticAssetURLs, identity.FullName) + emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderInvitation( + ctx, "/auth/signup-from-invitation", invitationToken, organization.Name, diff --git a/pkg/iam/service.go b/pkg/iam/service.go index e237e38ff..3ae13e40d 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -11,7 +11,6 @@ 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" @@ -29,7 +28,6 @@ type ( pg *pg.Client fm *filemanager.Service hp *passwdhash.Profile - emailStaticAssetURLs emails.StaticAssetURLs encryptionKey cipher.EncryptionKey baseURL string tokenSecret string @@ -84,7 +82,6 @@ func NewService( pgClient *pg.Client, fm *filemanager.Service, hp *passwdhash.Profile, - emailStaticAssetURLs emails.StaticAssetURLs, cfg Config, ) (*Service, error) { if cfg.Bucket == "" { @@ -107,7 +104,6 @@ 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 683296db9..69e32553f 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -543,9 +543,10 @@ func (s *DocumentService) SendSigningNotifications( return fmt.Errorf("cannot create signing request token: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, people.FullName) + emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, people.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderDocumentSigning( + ctx, "/documents/signing-requests", token, organization.Name, @@ -1823,9 +1824,10 @@ func (s *DocumentService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, recipientName) + emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, recipientName) subject, textBody, htmlBody, err := emailPresenter.RenderDocumentExport( + ctx, downloadURL, ) if err != nil { diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index e53402a21..4de91c6f4 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -800,9 +800,10 @@ func (s FrameworkService) SendExportEmail( return fmt.Errorf("cannot generate download URL: %w", err) } - emailPresenter := emails.NewPresenter(s.svc.baseURL, s.svc.emailStaticAssetURLs, recipientName) + emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, recipientName) subject, textBody, htmlBody, err := emailPresenter.RenderFrameworkExport( + ctx, downloadURL, ) if err != nil { diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 77c6df0d0..3ceb78646 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -23,7 +23,6 @@ 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" @@ -50,19 +49,18 @@ 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 - emailStaticAssetURLs emails.StaticAssetURLs + 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 } TenantService struct { @@ -75,7 +73,6 @@ type ( tokenSecret string agent *agents.Agent fileManager *filemanager.Service - emailStaticAssetURLs emails.StaticAssetURLs Frameworks *FrameworkService Measures *MeasureService Tasks *TaskService @@ -131,7 +128,6 @@ 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") @@ -140,19 +136,18 @@ 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, - emailStaticAssetURLs: emailStaticAssetURLs, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + baseURL: baseURL, + tokenSecret: tokenSecret, + agentConfig: agentConfig, + html2pdfConverter: html2pdfConverter, + acmeService: acmeService, + fileManager: fileManagerService, + logger: logger, + slack: slackService, } return svc, nil @@ -160,16 +155,15 @@ 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, - emailStaticAssetURLs: s.emailStaticAssetURLs, + 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, } tenantService.Frameworks = &FrameworkService{ diff --git a/pkg/probo/trust_center_access_service.go b/pkg/probo/trust_center_access_service.go index 950360380..bfbf1ce5e 100644 --- a/pkg/probo/trust_center_access_service.go +++ b/pkg/probo/trust_center_access_service.go @@ -417,9 +417,9 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con return fmt.Errorf("cannot get compliance page email presenter config: %w", err) } - emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, access.Name) + emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, access.Name) - subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(organization.Name) + subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name) if err != nil { return fmt.Errorf("cannot render trust center access email: %w", err) } diff --git a/pkg/probo/trust_center_service.go b/pkg/probo/trust_center_service.go index bf8e17f8b..994068aaf 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, s.svc.emailStaticAssetURLs) + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.bucket, s.svc.baseURL) ) scope := coredata.NewScopeFromObjectID(compliancePageID) @@ -669,13 +669,13 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, complianc return emailPresenterCfg, nil } - presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, logoFile, 7*24*time.Hour) - if err != nil { - return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + emailPresenterCfg.SenderCompanyLogo = emails.Asset{ + Name: logoFile.FileName, + ObjectKey: logoFile.FileKey, + BucketName: logoFile.BucketName, + MimeType: logoFile.MimeType, } - emailPresenterCfg.SenderCompanyLogoURL = presignedURL - emailPresenterCfg.SenderCompanyName = organization.Name if organization.WebsiteURL != nil { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 8e4c7f3ac..425ec2940 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -293,12 +293,11 @@ func (impl *Implm) Run( } } - emailStaticAssetURLs, err := emails.GenerateStaticAssetURLs( + if err := emails.UpdloadStaticAssets( ctx, s3Client, impl.cfg.AWS.Bucket, - ) - if err != nil { + ); err != nil { return fmt.Errorf("cannot generate email static asset URLs: %w", err) } @@ -307,7 +306,6 @@ func (impl *Implm) Run( pgClient, fileManagerService, hp, - emailStaticAssetURLs, iam.Config{ DisableSignup: impl.cfg.Auth.DisableSignup, InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second, @@ -387,7 +385,6 @@ func (impl *Implm) Run( l.Named("probo"), slackService, iamService, - emailStaticAssetURLs, ) if err != nil { return fmt.Errorf("cannot create probo service: %w", err) @@ -405,7 +402,6 @@ 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 1b11ddfa7..ab357b097 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -22,7 +22,6 @@ 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" @@ -36,19 +35,18 @@ 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 - emailStaticAssetURLs emails.StaticAssetURLs + 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 } TenantService struct { @@ -63,7 +61,6 @@ type ( html2pdfConverter *html2pdf.Converter fileManager *filemanager.Service logger *log.Logger - emailStaticAssetURLs emails.StaticAssetURLs TrustCenters *TrustCenterService Documents *DocumentService Audits *AuditService @@ -90,38 +87,35 @@ 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, - emailStaticAssetURLs: emailStaticAssetURLs, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + slackSigningSecret: slackSigningSecret, + baseURL: baseURL, + iam: iam, + html2pdfConverter: html2pdfConverter, + fileManager: fileManagerService, + logger: logger, + slack: slack, } } 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, - emailStaticAssetURLs: s.emailStaticAssetURLs, + 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, } tenantService.TrustCenters = &TrustCenterService{svc: tenantService} diff --git a/pkg/trust/trust_center_access_service.go b/pkg/trust/trust_center_access_service.go index ad9ac2007..387809c83 100644 --- a/pkg/trust/trust_center_access_service.go +++ b/pkg/trust/trust_center_access_service.go @@ -483,9 +483,9 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co return fmt.Errorf("cannot get compliance page email presenter config: %w", err) } - emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, access.Name) + emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, access.Name) - subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(organization.Name) + subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name) if err != nil { return fmt.Errorf("cannot render trust center access email: %w", err) } @@ -606,9 +606,10 @@ func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail( if fullName == "" { fullName = access.Email.Username() } - emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, fullName) + emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, fullName) subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterDocumentAccessRejected( + ctx, fileNames, organization.Name, ) diff --git a/pkg/trust/trust_center_service.go b/pkg/trust/trust_center_service.go index 5560f135d..924a7c602 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, s.svc.emailStaticAssetURLs) + emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.bucket, s.svc.baseURL) ) scope := coredata.NewScopeFromObjectID(compliancePageID) @@ -288,13 +288,13 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, complianc // If logo exists, then we will brand the emails with the org as a sender - presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, logoFile, 7*24*time.Hour) - if err != nil { - return emailPresenterCfg, fmt.Errorf("cannot generate file URL: %w", err) + emailPresenterCfg.SenderCompanyLogo = emails.Asset{ + Name: logoFile.FileName, + ObjectKey: logoFile.FileKey, + BucketName: logoFile.BucketName, + MimeType: logoFile.MimeType, } - emailPresenterCfg.SenderCompanyLogoURL = presignedURL - emailPresenterCfg.SenderCompanyName = organization.Name if organization.WebsiteURL != nil {