Use html in emails
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
.turbo
|
||||
.vscode
|
||||
dist/
|
||||
.react-email/
|
||||
sbom.json
|
||||
sbom-docker.json
|
||||
*_sbom.json
|
||||
|
||||
@@ -5,7 +5,7 @@ project_name: probod
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- make @probo/console @probo/trust
|
||||
- make @probo/emails @probo/console @probo/trust
|
||||
|
||||
builds:
|
||||
- id: probod
|
||||
|
||||
10
GNUmakefile
10
GNUmakefile
@@ -42,7 +42,7 @@ all: build
|
||||
lint: vet # npm-lint
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
vet: @probo/emails
|
||||
$(GO_VET) ./...
|
||||
|
||||
.PHONY: npm-lint
|
||||
@@ -72,7 +72,7 @@ test-bench: TEST_FLAGS+=-bench=.
|
||||
test-bench: test ## Run benchmark tests
|
||||
|
||||
.PHONY: build
|
||||
build: @probo/console @probo/trust bin/probod
|
||||
build: @probo/emails @probo/console @probo/trust bin/probod
|
||||
|
||||
.PHONY: sbom-docker
|
||||
sbom-docker: docker-build
|
||||
@@ -114,9 +114,14 @@ bin/probod: pkg/server/api/console/v1/schema/schema.go \
|
||||
pkg/server/api/trust/v1/schema/schema.go \
|
||||
pkg/server/api/trust/v1/types/types.go \
|
||||
pkg/server/api/trust/v1/v1_resolver.go \
|
||||
@probo/emails \
|
||||
vet
|
||||
$(GO_BUILD) -o $(PROBOD_BIN) $(PROBOD_SRC)
|
||||
|
||||
.PHONY: @probo/emails
|
||||
@probo/emails:
|
||||
$(NPM) --workspace $@ run build
|
||||
|
||||
.PHONY: @probo/console
|
||||
@probo/console: NODE_ENV=production
|
||||
@probo/console:
|
||||
@@ -159,6 +164,7 @@ clean: ## Clean the project (node_modules and build artifacts)
|
||||
$(RM) -rf bin/*
|
||||
$(RM) -rf node_modules
|
||||
$(RM) -rf apps/{console,trust}/{dist,node_modules}
|
||||
$(RM) -rf packages/emails/{dist,node_modules}
|
||||
$(RM) -rf sbom-docker.json sbom.json
|
||||
$(RM) -rf coverage.out coverage.html
|
||||
|
||||
|
||||
1424
package-lock.json
generated
1424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
167
packages/emails/emails.go
Normal file
167
packages/emails/emails.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// 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 emails
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
texttemplate "text/template"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var Templates embed.FS
|
||||
|
||||
const (
|
||||
subjectConfirmEmail = "Confirm your email address"
|
||||
subjectPasswordReset = "Reset your password"
|
||||
subjectInvitation = "Invitation to join %s on Probo"
|
||||
subjectDocumentSigning = "Action Required – Please review and sign %s compliance documents"
|
||||
subjectDocumentExport = "Your document export is ready"
|
||||
subjectFrameworkExport = "Your framework export is ready"
|
||||
subjectTrustCenterAccess = "Trust Center Access Invitation - %s"
|
||||
)
|
||||
|
||||
var (
|
||||
confirmEmailHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/confirm-email.html.tmpl"))
|
||||
confirmEmailTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/confirm-email.txt.tmpl"))
|
||||
passwordResetHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/password-reset.html.tmpl"))
|
||||
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
|
||||
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
|
||||
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.tmpl"))
|
||||
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
|
||||
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
|
||||
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.tmpl"))
|
||||
documentExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-export.txt.tmpl"))
|
||||
frameworkExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/framework-export.html.tmpl"))
|
||||
frameworkExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/framework-export.txt.tmpl"))
|
||||
trustCenterAccessHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/trust-center-access.html.tmpl"))
|
||||
trustCenterAccessTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-access.txt.tmpl"))
|
||||
)
|
||||
|
||||
func RenderConfirmEmail(fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
ConfirmationUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
ConfirmationUrl: confirmationUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data)
|
||||
return subjectConfirmEmail, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderPasswordReset(fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
ResetUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
ResetUrl: resetUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(passwordResetTextTemplate, passwordResetHTMLTemplate, data)
|
||||
return subjectPasswordReset, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderInvitation(fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
OrganizationName string
|
||||
InvitationUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
InvitationUrl: invitationUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(invitationTextTemplate, invitationHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderDocumentSigning(fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
OrganizationName string
|
||||
SigningUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
SigningUrl: signingUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectDocumentSigning, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderDocumentExport(fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
DownloadUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
DownloadUrl: downloadUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(documentExportTextTemplate, documentExportHTMLTemplate, data)
|
||||
return subjectDocumentExport, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderFrameworkExport(fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
DownloadUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
DownloadUrl: downloadUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(frameworkExportTextTemplate, frameworkExportHTMLTemplate, data)
|
||||
return subjectFrameworkExport, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderTrustCenterAccess(fullName, organizationName, accessUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
OrganizationName string
|
||||
AccessUrl string
|
||||
}{
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
AccessUrl: accessUrl,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(trustCenterAccessTextTemplate, trustCenterAccessHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectTrustCenterAccess, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
|
||||
var textBuf bytes.Buffer
|
||||
if err := textTemplate.Execute(&textBuf, data); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to execute text template: %w", err)
|
||||
}
|
||||
textBody = textBuf.String()
|
||||
|
||||
var htmlBuf bytes.Buffer
|
||||
if err := htmlTemplate.Execute(&htmlBuf, data); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to execute html template: %w", err)
|
||||
}
|
||||
htmlBodyStr := htmlBuf.String()
|
||||
htmlBody = &htmlBodyStr
|
||||
|
||||
return textBody, htmlBody, nil
|
||||
}
|
||||
22
packages/emails/package.json
Normal file
22
packages/emails/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@probo/emails",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "email dev --dir src --port 3003",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.5.6",
|
||||
"react": "^19.0.0",
|
||||
"react-email": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19.0.6",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
74
packages/emails/scripts/build.ts
Normal file
74
packages/emails/scripts/build.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render } from '@react-email/components';
|
||||
import { copyFile, mkdir, writeFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as React from 'react';
|
||||
|
||||
import ConfirmEmail from '../src/ConfirmEmail';
|
||||
import DocumentExport from '../src/DocumentExport';
|
||||
import DocumentSigning from '../src/DocumentSigning';
|
||||
import FrameworkExport from '../src/FrameworkExport';
|
||||
import Invitation from '../src/Invitation';
|
||||
import PasswordReset from '../src/PasswordReset';
|
||||
import TrustCenterAccess from '../src/TrustCenterAccess';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
type TemplateConfig = {
|
||||
name: string;
|
||||
render: () => React.ReactElement;
|
||||
};
|
||||
|
||||
const templates: TemplateConfig[] = [
|
||||
{
|
||||
name: 'confirm-email',
|
||||
render: () => ConfirmEmail()
|
||||
},
|
||||
{
|
||||
name: 'password-reset',
|
||||
render: () => PasswordReset()
|
||||
},
|
||||
{
|
||||
name: 'invitation',
|
||||
render: () => Invitation()
|
||||
},
|
||||
{
|
||||
name: 'document-signing',
|
||||
render: () => DocumentSigning()
|
||||
},
|
||||
{
|
||||
name: 'document-export',
|
||||
render: () => DocumentExport()
|
||||
},
|
||||
{
|
||||
name: 'framework-export',
|
||||
render: () => FrameworkExport()
|
||||
},
|
||||
{
|
||||
name: 'trust-center-access',
|
||||
render: () => TrustCenterAccess()
|
||||
},
|
||||
];
|
||||
|
||||
async function build() {
|
||||
const outputDir = join(__dirname, '..', 'dist');
|
||||
const templatesDir = join(__dirname, '..', 'templates');
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
for (const template of templates) {
|
||||
const html = await render(template.render(), { pretty: true });
|
||||
|
||||
const htmlPath = join(outputDir, `${template.name}.html.tmpl`);
|
||||
const txtSrcPath = join(templatesDir, `${template.name}.txt`);
|
||||
const txtDstPath = join(outputDir, `${template.name}.txt.tmpl`);
|
||||
|
||||
await writeFile(htmlPath, html);
|
||||
await copyFile(txtSrcPath, txtDstPath);
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error('Failed to build email templates:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
packages/emails/src/ConfirmEmail.tsx
Normal file
25
packages/emails/src/ConfirmEmail.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const ConfirmEmail = () => {
|
||||
return (
|
||||
<EmailLayout subject="Confirm your email address">
|
||||
<Text style={bodyText}>
|
||||
Thanks for joining Probo! Please confirm your email address by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.ConfirmationUrl}}'}>
|
||||
Confirm Email Address
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
If you did not sign up for Probo, please ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmEmail;
|
||||
25
packages/emails/src/DocumentExport.tsx
Normal file
25
packages/emails/src/DocumentExport.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const DocumentExport = () => {
|
||||
return (
|
||||
<EmailLayout subject="Your document export is ready">
|
||||
<Text style={bodyText}>
|
||||
Your document export has been completed successfully. Click the button below to download it:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.DownloadUrl}}'}>
|
||||
Download Export
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
This link will expire in 24 hours.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentExport;
|
||||
36
packages/emails/src/DocumentSigning.tsx
Normal file
36
packages/emails/src/DocumentSigning.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const DocumentSigning = () => {
|
||||
return (
|
||||
<EmailLayout subject={'Action Required – Please review and sign {{.OrganizationName}} compliance documents'} organizationName={'{{.OrganizationName}}'}>
|
||||
<Text style={bodyText}>
|
||||
You're receiving this message because your company, <strong>{'{{.OrganizationName}}'}</strong>, has shared a new compliance document that requires your review and signature.
|
||||
</Text>
|
||||
<Text style={bodyText}>
|
||||
To stay compliant with company policies, please take a moment to review and sign the document by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.SigningUrl}}'}>
|
||||
Review and Sign Documents
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
If you have any questions, please contact your security team.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
This process is managed securely by Probo, acting as the compliance partner on behalf of <strong>{'{{.OrganizationName}}'}</strong>.
|
||||
Thank you for your prompt attention to this matter.
|
||||
</Text>
|
||||
<Text style={footerText}>
|
||||
Best regards,
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSigning;
|
||||
25
packages/emails/src/FrameworkExport.tsx
Normal file
25
packages/emails/src/FrameworkExport.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const FrameworkExport = () => {
|
||||
return (
|
||||
<EmailLayout subject="Your framework export is ready">
|
||||
<Text style={bodyText}>
|
||||
Your framework export has been completed successfully. Click the button below to download it:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.DownloadUrl}}'}>
|
||||
Download Export
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
This link will expire in 24 hours.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrameworkExport;
|
||||
25
packages/emails/src/Invitation.tsx
Normal file
25
packages/emails/src/Invitation.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const Invitation = () => {
|
||||
return (
|
||||
<EmailLayout subject={'Invitation to join {{.OrganizationName}} on Probo'} organizationName={'{{.OrganizationName}}'}>
|
||||
<Text style={bodyText}>
|
||||
You have been invited to join organization <strong>{'{{.OrganizationName}}'}</strong> on Probo. Click the button below to accept the invitation:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.InvitationUrl}}'}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
If you don't want to accept the invitation, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Invitation;
|
||||
25
packages/emails/src/PasswordReset.tsx
Normal file
25
packages/emails/src/PasswordReset.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const PasswordReset = () => {
|
||||
return (
|
||||
<EmailLayout subject="Reset your password">
|
||||
<Text style={bodyText}>
|
||||
You have requested a password reset for your Probo account. Click the button below to reset your password:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.ResetUrl}}'}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
If you did not request this password reset, please ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordReset;
|
||||
25
packages/emails/src/TrustCenterAccess.tsx
Normal file
25
packages/emails/src/TrustCenterAccess.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const TrustCenterAccess = () => {
|
||||
return (
|
||||
<EmailLayout subject={`Trust Center Access Invitation - ${'{{.OrganizationName}}'}`} organizationName={'{{.OrganizationName}}'}>
|
||||
<Text style={bodyText}>
|
||||
You have been granted access to <strong>{'{{.OrganizationName}}'}</strong>'s Trust Center! Click the button below to access it:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.AccessUrl}}'}>
|
||||
Access Trust Center
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
This link will expire in 7 days.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrustCenterAccess;
|
||||
149
packages/emails/src/components/EmailLayout.tsx
Normal file
149
packages/emails/src/components/EmailLayout.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { ProboLogo } from './ProboLogo';
|
||||
|
||||
interface EmailLayoutProps {
|
||||
subject: string;
|
||||
children: React.ReactNode;
|
||||
organizationName?: string;
|
||||
}
|
||||
|
||||
export const EmailLayout = ({
|
||||
subject,
|
||||
children,
|
||||
organizationName,
|
||||
}: EmailLayoutProps) => {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>{subject}</title>
|
||||
</Head>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={content}>
|
||||
<Section style={logoSection}>
|
||||
<Link href="https://www.getprobo.com">
|
||||
<ProboLogo />
|
||||
</Link>
|
||||
</Section>
|
||||
|
||||
<Text style={text}>Hi {'{{.FullName}}'},</Text>
|
||||
|
||||
{children}
|
||||
</Section>
|
||||
|
||||
<Section style={footerSection}>
|
||||
<Text style={footerAddress}>
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
|
||||
const main: React.CSSProperties = {
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
backgroundColor: '#fffffb',
|
||||
WebkitTextSizeAdjust: '100%',
|
||||
};
|
||||
|
||||
const container: React.CSSProperties = {
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
margin: '40px auto',
|
||||
};
|
||||
|
||||
export const headerSection: React.CSSProperties = {
|
||||
padding: '40px 40px 30px 40px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#101e1c',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
};
|
||||
|
||||
export const h1: React.CSSProperties = {
|
||||
margin: '0',
|
||||
color: '#e6ff03',
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
lineHeight: '1.3',
|
||||
};
|
||||
|
||||
const content: React.CSSProperties = {
|
||||
padding: '40px',
|
||||
};
|
||||
|
||||
const text: React.CSSProperties = {
|
||||
margin: '0 0 20px 0',
|
||||
color: '#141e12',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
};
|
||||
|
||||
export const buttonContainer: React.CSSProperties = {
|
||||
padding: '10px 0 30px 0',
|
||||
};
|
||||
|
||||
export const button: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
padding: '14px 28px',
|
||||
backgroundColor: '#101e1c',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
lineHeight: '20px',
|
||||
};
|
||||
|
||||
export const footerText: React.CSSProperties = {
|
||||
margin: '0',
|
||||
color: '#6b716a',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
};
|
||||
|
||||
const footerSection: React.CSSProperties = {
|
||||
padding: '30px 40px',
|
||||
borderTop: '1px solid #ecefec',
|
||||
};
|
||||
|
||||
const footerAddress: React.CSSProperties = {
|
||||
margin: '10px 0 0 0',
|
||||
color: '#6b716a',
|
||||
fontSize: '12px',
|
||||
lineHeight: '18px',
|
||||
};
|
||||
|
||||
const logoSection: React.CSSProperties = {
|
||||
marginBottom: '30px',
|
||||
};
|
||||
|
||||
export const bodyText: React.CSSProperties = {
|
||||
margin: '0 0 30px 0',
|
||||
color: '#141e12',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
};
|
||||
34
packages/emails/src/components/ProboLogo.tsx
Normal file
34
packages/emails/src/components/ProboLogo.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export function ProboLogo() {
|
||||
return (
|
||||
<svg width="220" height="60" viewBox="0 0 1098 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_342_2394)">
|
||||
<rect width="300" height="300" rx="70" fill="#101E1C"/>
|
||||
<g clipPath="url(#clip1_342_2394)">
|
||||
<rect width="320" height="320" transform="translate(-9.69238 -10)" fill="#101E1C"/>
|
||||
<path d="M238.194 83.0279C274.406 137.651 160.574 258.266 -29.4255 142.266" stroke="#E6FF03" strokeWidth="42.6667"/>
|
||||
<path d="M238.194 83.0279C274.406 137.651 160.574 258.266 -29.4255 142.266" stroke="url(#paint0_linear_342_2394)" strokeWidth="42.6667"/>
|
||||
<path d="M220.112 94.3364C226.365 104.327 239.53 107.363 249.515 101.118C259.501 94.873 262.527 81.7113 256.274 71.7206L220.112 94.3364ZM97.9074 324.933L118.61 319.8C87.9762 196.62 115.788 133.041 146.656 105.377C162.52 91.1595 180.159 85.27 194.453 84.9721C209.505 84.6583 217.534 90.218 220.112 94.3364L238.193 83.0285L256.274 71.7206C242.999 50.5115 217.649 41.8122 193.53 42.315C168.652 42.8335 141.327 52.8361 118.165 73.5935C70.9833 115.879 44.5053 198.58 77.2047 330.066L97.9074 324.933Z" fill="#E6FF03"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M819.441 0V91.5413C820.437 90.5458 822.066 89.5503 824.057 88.2381C826.365 86.609 830.98 84.618 837.903 82.3103C844.827 80.0025 852.383 78.6902 859.985 78.6902C880.393 78.6902 897.543 85.6135 911.345 99.7769C925.191 113.623 932.069 130.411 932.069 150.502C932.069 170.594 925.146 187.065 910.983 201.228C897.136 215.391 880.031 222.315 859.94 222.315C839.849 222.315 823.378 215.708 809.215 202.902C795.051 190.051 787.811 172.268 787.811 149.552V0H819.441ZM859.94 109.008C837.225 109.008 819.441 126.475 819.441 150.502C819.441 174.53 836.546 191.997 859.94 191.997C883.335 191.997 900.439 174.214 900.439 150.502C900.439 126.791 882.656 109.008 859.94 109.008Z" fill="#101E1C"/>
|
||||
<path d="M643.005 99.781C657.168 85.6176 673.956 78.6943 694.047 78.6943C714.139 78.6943 730.61 85.6176 744.773 99.781C758.936 113.944 765.86 130.732 765.86 150.507C765.86 170.281 758.936 187.386 744.773 201.549C730.61 215.395 713.822 222.319 694.047 222.319C674.273 222.319 657.168 215.395 643.005 201.549C628.842 187.386 621.918 170.598 621.918 150.507C621.918 130.415 628.842 113.944 643.005 99.781ZM653.548 150.507C653.548 162.679 657.485 172.589 665.087 180.462C673.006 188.019 682.554 192.001 694.047 192.001C705.541 192.001 715.134 188.064 722.691 180.462C730.61 172.543 734.546 162.679 734.546 150.507C734.546 138.334 730.61 128.424 722.691 120.868C715.134 112.949 705.586 109.012 694.047 109.012C682.509 109.012 672.961 112.949 665.087 120.868C657.53 128.424 653.548 138.334 653.548 150.507Z" fill="#101E1C"/>
|
||||
<path d="M975.084 99.781C989.248 85.6176 1006.04 78.6943 1026.13 78.6943C1046.22 78.6943 1062.69 85.6176 1076.85 99.781C1091.02 113.944 1097.94 130.732 1097.94 150.507C1097.94 170.281 1091.02 187.386 1076.85 201.549C1062.69 215.395 1045.9 222.319 1026.13 222.319C1006.35 222.319 989.248 215.395 975.084 201.549C960.921 187.386 953.998 170.598 953.998 150.507C953.998 130.415 960.921 113.944 975.084 99.781ZM985.628 150.507C985.628 162.679 989.565 172.589 997.167 180.462C1005.09 188.019 1014.63 192.001 1026.13 192.001C1037.62 192.001 1047.21 188.064 1054.77 180.462C1062.69 172.543 1066.63 162.679 1066.63 150.507C1066.63 138.334 1062.69 128.424 1054.77 120.868C1047.21 112.949 1037.67 109.012 1026.13 109.012C1014.59 109.012 1005.04 112.949 997.167 120.868C989.61 128.424 985.628 138.334 985.628 150.507Z" fill="#101E1C"/>
|
||||
<path d="M411.63 209.454V299.999H380V151.488C380 128.772 387.24 110.989 401.403 98.138C415.567 85.2869 432.671 78.7256 452.129 78.7256C471.587 78.7256 489.325 85.6489 503.171 99.8122C517.335 113.976 524.258 130.763 524.258 150.538C524.258 170.312 517.335 187.417 503.533 201.58C489.687 215.427 472.582 222.35 452.174 222.35C436.699 222.35 421.857 217.101 416.291 212.802L411.675 209.499L411.63 209.454ZM452.129 108.998C428.735 108.998 411.63 126.781 411.63 150.493C411.63 174.204 429.413 191.987 452.129 191.987C474.845 191.987 492.628 174.52 492.628 150.493C492.628 126.465 474.845 108.998 452.129 108.998Z" fill="#101E1C"/>
|
||||
<path d="M576.136 220.64H544.868V136.973C544.868 96.4737 567.583 80.3193 598.218 80.3193H605.775V110.637H599.847C584.054 110.637 576.136 119.551 576.136 136.973V220.64Z" fill="#101E1C"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_342_2394" x1="216.193" y1="173.319" x2="-18.0923" y2="173.319" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#101E1C" stopOpacity="0"/>
|
||||
<stop offset="1" stopColor="#101E1C"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_342_2394">
|
||||
<rect width="300" height="300" rx="70" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_342_2394">
|
||||
<rect width="320" height="320" fill="white" transform="translate(-9.69238 -10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
11
packages/emails/templates/confirm-email.txt
Normal file
11
packages/emails/templates/confirm-email.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
Thanks for joining Probo! Please confirm your email address by clicking the link below:
|
||||
|
||||
{{.ConfirmationUrl}}
|
||||
|
||||
If you did not sign up for Probo, please ignore this email.
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
11
packages/emails/templates/document-export.txt
Normal file
11
packages/emails/templates/document-export.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
Your document export has been completed successfully. Click the link below to download it:
|
||||
|
||||
{{.DownloadUrl}}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
19
packages/emails/templates/document-signing.txt
Normal file
19
packages/emails/templates/document-signing.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
You're receiving this message because your company, {{.OrganizationName}}, has shared a new compliance document that requires your review and signature.
|
||||
|
||||
To stay compliant with company policies, please take a moment to review and sign the document by clicking the link below:
|
||||
|
||||
{{.SigningUrl}}
|
||||
|
||||
If you have any questions, please contact your security team.
|
||||
|
||||
This process is managed securely by Probo, acting as the compliance partner on behalf of {{.OrganizationName}}.
|
||||
|
||||
Thank you for your prompt attention to this matter.
|
||||
|
||||
Best regards,
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
11
packages/emails/templates/framework-export.txt
Normal file
11
packages/emails/templates/framework-export.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
Your framework export has been completed successfully. Click the link below to download it:
|
||||
|
||||
{{.DownloadUrl}}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
@@ -1,10 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have been invited to join organization {{.OrganizationName}}. Please click the link below to accept the invitation:
|
||||
You have been invited to join organization {{.OrganizationName}} on Probo. Click the link below to accept the invitation:
|
||||
|
||||
{{.InvitationURL}}
|
||||
{{.InvitationUrl}}
|
||||
|
||||
If you don't want to accept the invitation, you can ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
11
packages/emails/templates/password-reset.txt
Normal file
11
packages/emails/templates/password-reset.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have requested a password reset for your Probo account. Click the link below to reset your password:
|
||||
|
||||
{{.ResetUrl}}
|
||||
|
||||
If you did not request this password reset, please ignore this email.
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
11
packages/emails/templates/trust-center-access.txt
Normal file
11
packages/emails/templates/trust-center-access.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Probo
|
||||
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have been granted access to {{.OrganizationName}}'s Trust Center! Click the link below to access it:
|
||||
|
||||
{{.AccessUrl}}
|
||||
|
||||
This link will expire in 7 days.
|
||||
|
||||
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US
|
||||
12
packages/emails/tsconfig.json
Normal file
12
packages/emails/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@probo/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have requested a password reset for your Probo account.
|
||||
|
||||
Please click the link below to reset your password:
|
||||
|
||||
{{.ResetURL}}
|
||||
|
||||
If you did not request this password reset, please ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
@@ -1,12 +0,0 @@
|
||||
Hi {{.FullName}},
|
||||
|
||||
Thanks for joining Probo!
|
||||
|
||||
Please confirm your email address by clicking the link below:
|
||||
|
||||
{{.ConfirmationURL}}
|
||||
|
||||
If you did not sign up for Probo, please ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
@@ -15,16 +15,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
@@ -100,18 +98,6 @@ const (
|
||||
TokenTypePasswordReset = "password_reset"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed emails/signup_confirmation.txt.tmpl
|
||||
signupEmailTemplateData string
|
||||
signupEmailTemplate = template.Must(template.New("signup").Parse(signupEmailTemplateData))
|
||||
signupEmailSubject = "Confirm your email address"
|
||||
|
||||
//go:embed emails/password_reset.txt.tmpl
|
||||
passwordResetEmailTemplateData string
|
||||
passwordResetEmailTemplate = template.Must(template.New("password_reset").Parse(passwordResetEmailTemplateData))
|
||||
passwordResetEmailSubject = "Reset your password"
|
||||
)
|
||||
|
||||
func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
@@ -204,23 +190,20 @@ func (s Service) ForgetPassword(
|
||||
return fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = passwordResetEmailTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"ResetURL": resetPasswordUrl.String(),
|
||||
},
|
||||
subject, textBody, htmlBody, err := emails.RenderPasswordReset(
|
||||
user.FullName,
|
||||
resetPasswordUrl.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot execute password reset template: %w", err)
|
||||
return fmt.Errorf("cannot render password reset email: %w", err)
|
||||
}
|
||||
|
||||
passwordResetEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
email,
|
||||
passwordResetEmailSubject,
|
||||
body.String(),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
if err := passwordResetEmail.Insert(ctx, conn); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
@@ -308,23 +291,20 @@ func (s Service) SignUp(
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = signupEmailTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"ConfirmationURL": confirmationUrl.String(),
|
||||
},
|
||||
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
|
||||
user.FullName,
|
||||
confirmationUrl.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot execute signup template: %w", err)
|
||||
return fmt.Errorf("cannot render confirmation email: %w", err)
|
||||
}
|
||||
|
||||
confirmationEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
user.EmailAddress,
|
||||
signupEmailSubject,
|
||||
body.String(),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := confirmationEmail.Insert(ctx, tx); err != nil {
|
||||
|
||||
@@ -15,15 +15,13 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
@@ -64,14 +62,6 @@ const (
|
||||
TokenTypeOrganizationInvitation = "organization_invitation"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed emails/invitation.txt.tmpl
|
||||
invitationEmailBodyData string
|
||||
|
||||
invitationEmailBodyTemplate = template.Must(template.New("invitation").Parse(invitationEmailBodyData))
|
||||
invitationEmailSubject = "Invitation to join organization"
|
||||
)
|
||||
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
@@ -681,21 +671,15 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
var err error
|
||||
var invitationURL string
|
||||
var recipientName string
|
||||
|
||||
if userExists {
|
||||
err = invitationEmailBodyTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"OrganizationName": organization.Name,
|
||||
"InvitationURL": fmt.Sprintf("https://%s/", s.hostname),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
recipientName = user.FullName
|
||||
invitationURL = fmt.Sprintf("https://%s/", s.hostname)
|
||||
} else {
|
||||
recipientName = fullName
|
||||
invitationData := coredata.InvitationData{
|
||||
InvitationID: invitationID,
|
||||
OrganizationID: organizationID,
|
||||
@@ -714,24 +698,24 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
return fmt.Errorf("failed to generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
err = invitationEmailBodyTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": fullName,
|
||||
"OrganizationName": organization.Name,
|
||||
"InvitationURL": fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName)),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
invitationURL = fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName))
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderInvitation(
|
||||
recipientName,
|
||||
organization.Name,
|
||||
invitationURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render invitation email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
fullName,
|
||||
emailAddress,
|
||||
invitationEmailSubject,
|
||||
body.String(),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
|
||||
@@ -32,6 +32,7 @@ type (
|
||||
RecipientName string `db:"recipient_name"`
|
||||
Subject string `db:"subject"`
|
||||
TextBody string `db:"text_body"`
|
||||
HtmlBody *string `db:"html_body"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
SentAt *time.Time `db:"sent_at"`
|
||||
@@ -46,7 +47,8 @@ func NewEmail(
|
||||
recipientName string,
|
||||
recipientEmail string,
|
||||
subject string,
|
||||
body string,
|
||||
textBody string,
|
||||
htmlBody *string,
|
||||
) *Email {
|
||||
now := time.Now()
|
||||
return &Email{
|
||||
@@ -54,7 +56,8 @@ func NewEmail(
|
||||
RecipientName: recipientName,
|
||||
RecipientEmail: recipientEmail,
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
TextBody: textBody,
|
||||
HtmlBody: htmlBody,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -65,8 +68,8 @@ func (e *Email) Insert(
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO emails (id, recipient_email, recipient_name, subject, text_body, created_at, updated_at)
|
||||
VALUES (@id, @recipient_email, @recipient_name, @subject, @text_body, @created_at, @updated_at)
|
||||
INSERT INTO emails (id, recipient_email, recipient_name, subject, text_body, html_body, created_at, updated_at)
|
||||
VALUES (@id, @recipient_email, @recipient_name, @subject, @text_body, @html_body, @created_at, @updated_at)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
@@ -75,6 +78,7 @@ VALUES (@id, @recipient_email, @recipient_name, @subject, @text_body, @created_a
|
||||
"recipient_name": e.RecipientName,
|
||||
"subject": e.Subject,
|
||||
"text_body": e.TextBody,
|
||||
"html_body": e.HtmlBody,
|
||||
"created_at": e.CreatedAt,
|
||||
"updated_at": e.UpdatedAt,
|
||||
}
|
||||
@@ -88,7 +92,7 @@ func (e *Email) LoadNextUnsentForUpdate(
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
SELECT id, recipient_email, recipient_name, subject, text_body, created_at, updated_at, sent_at
|
||||
SELECT id, recipient_email, recipient_name, subject, text_body, html_body, created_at, updated_at, sent_at
|
||||
FROM emails
|
||||
WHERE sent_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
|
||||
1
pkg/coredata/migrations/20251014T132711Z.sql
Normal file
1
pkg/coredata/migrations/20251014T132711Z.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE emails ADD COLUMN html_body TEXT;
|
||||
@@ -149,6 +149,10 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
|
||||
To(email.RecipientName, email.RecipientEmail).
|
||||
Text([]byte(email.TextBody))
|
||||
|
||||
if email.HtmlBody != nil {
|
||||
mail = mail.HTML([]byte(*email.HtmlBody))
|
||||
}
|
||||
|
||||
envelope, err := mail.Build()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build email: %w", err)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/docgen"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
@@ -79,14 +80,6 @@ const (
|
||||
TokenTypeSigningRequest = "signing_request"
|
||||
|
||||
documentExportEmailExpiresIn = 24 * time.Hour
|
||||
documentExportEmailSubject = "Your document export is ready"
|
||||
documentExportEmailBody = `
|
||||
Your document export has been completed successfully.
|
||||
|
||||
You can download the export using the link below:
|
||||
[1] %s
|
||||
|
||||
This link will expire in 24 hours.`
|
||||
|
||||
maxFilenameLength = 200
|
||||
)
|
||||
@@ -433,11 +426,12 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
return fmt.Errorf("cannot load people: %w", err)
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
for _, people := range peoples {
|
||||
now := time.Now()
|
||||
|
||||
emailID := gid.New(s.svc.scope.GetTenantID(), coredata.EmailEntityType)
|
||||
|
||||
token, err := statelesstoken.NewToken(
|
||||
s.svc.tokenSecret,
|
||||
TokenTypeSigningRequest,
|
||||
@@ -460,16 +454,23 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
email := &coredata.Email{
|
||||
ID: emailID,
|
||||
RecipientEmail: people.PrimaryEmailAddress,
|
||||
RecipientName: people.FullName,
|
||||
Subject: "Probo - Documents Signing Request",
|
||||
TextBody: fmt.Sprintf("Hi,\nYou have documents awaiting your signature. Please follow this link to sign them: %s", signRequestURL.String()),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentSigning(
|
||||
people.FullName,
|
||||
organization.Name,
|
||||
signRequestURL.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render signing request email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
people.FullName,
|
||||
people.PrimaryEmailAddress,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
@@ -1568,11 +1569,20 @@ func (s *DocumentService) SendExportEmail(
|
||||
return fmt.Errorf("cannot generate download URL: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentExport(
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render document export email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
documentExportEmailSubject,
|
||||
fmt.Sprintf(documentExportEmailBody, downloadURL),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/html2pdf"
|
||||
@@ -39,14 +40,6 @@ import (
|
||||
const (
|
||||
maxStateOfApplicabilityLimit = 10_000
|
||||
frameworkExportEmailExpiresIn = 24 * time.Hour
|
||||
frameworkExportEmailSubject = "Your framework export is ready"
|
||||
frameworkExportEmailBody = `
|
||||
Your framework export has been completed successfully.
|
||||
|
||||
You can download the export using the link below:
|
||||
[1] %s
|
||||
|
||||
This link will expire in 24 hours.`
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -715,11 +708,20 @@ func (s FrameworkService) SendExportEmail(
|
||||
return fmt.Errorf("cannot generate download URL: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderFrameworkExport(
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render framework export email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
frameworkExportEmailSubject,
|
||||
fmt.Sprintf(frameworkExportEmailBody, downloadURL),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
@@ -57,21 +58,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
trustCenterAccessEmailSubject = "Trust Center Access Invitation - %s"
|
||||
trustCenterAccessEmailTemplate = `
|
||||
You have been granted access to %s's Trust Center!
|
||||
|
||||
Click the link below to access it:
|
||||
|
||||
[1] %s
|
||||
|
||||
This link will expire in 7 days.
|
||||
|
||||
If the link above doesn't work, copy and paste the entire URL into your browser.
|
||||
`
|
||||
)
|
||||
|
||||
func (s TrustCenterAccessService) ListForTrustCenterID(
|
||||
ctx context.Context,
|
||||
trustCenterID gid.GID,
|
||||
@@ -421,11 +407,21 @@ func (s TrustCenterAccessService) sendTrustCenterAccessEmail(
|
||||
companyName string,
|
||||
accessURL string,
|
||||
) error {
|
||||
subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess(
|
||||
name,
|
||||
companyName,
|
||||
accessURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render trust center access email: %w", err)
|
||||
}
|
||||
|
||||
accessEmail := coredata.NewEmail(
|
||||
name,
|
||||
email,
|
||||
fmt.Sprintf(trustCenterAccessEmailSubject, companyName),
|
||||
fmt.Sprintf(trustCenterAccessEmailTemplate, companyName, accessURL),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
if err := accessEmail.Insert(ctx, tx); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user