Use html in emails

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-14 16:30:33 +02:00
parent 00c0a56f50
commit ae45410b82
34 changed files with 2211 additions and 208 deletions

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
Hi {{.FullName}},
You have been invited to join organization {{.OrganizationName}}. Please click the link below to accept the invitation:
{{.InvitationURL}}
If you don't want to accept the invitation, you can ignore this email.
Thanks,
Probo Team

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE emails ADD COLUMN html_body TEXT;

View File

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

View File

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

View File

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

View File

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