Use html in emails
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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