Files

270 lines
7.9 KiB
Go

package email
import (
"context"
"errors"
"fmt"
htemplate "html/template"
"net"
"net/url"
"path"
"strings"
ttemplate "text/template"
"time"
"github.com/italypaleale/go-kit/emailer"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/resources"
)
type Module struct {
db *gorm.DB
htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template
}
type template[V any] struct {
path string
title func(data *templateData[V]) string
}
type templateData[V any] struct {
AppName string
LogoURL string
Data *V
}
type address struct {
name string
email string
}
func New(db *gorm.DB) (*Module, error) {
// Preload both template variants so missing or invalid embedded templates fail during startup
htmlTemplates, err := prepareHTMLTemplates(templatePaths)
if err != nil {
return nil, fmt.Errorf("prepare HTML templates: %w", err)
}
textTemplates, err := prepareTextTemplates(templatePaths)
if err != nil {
return nil, fmt.Errorf("prepare text templates: %w", err)
}
return &Module{
db: db,
htmlTemplates: htmlTemplates,
textTemplates: textTemplates,
}, nil
}
func (m *Module) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserID string) error {
// Resolve the recipient from the database so test emails use the same user identity as notification emails
var user model.User
err := m.db.
WithContext(ctx).
First(&user, "id = ?", recipientUserID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.UserNotFound()
}
if err != nil {
return err
}
if user.Email == nil {
return apperror.UserEmailNotSet()
}
return send(ctx, m, dbConfig, address{
name: user.FullName(),
email: *user.Email,
}, testTemplate, nil)
}
func (m *Module) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error {
return send(ctx, m, dbConfig, address{
name: userFullName,
email: userEmail,
}, emailVerificationTemplate, &emailVerificationTemplateData{
UserFullName: userFullName,
VerificationLink: verificationLink,
})
}
func (m *Module) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, code, loginLink, loginLinkWithCode, expirationString string) error {
return send(ctx, m, dbConfig, address{
name: userFullName,
email: userEmail,
}, oneTimeAccessTemplate, &oneTimeAccessTemplateData{
Code: code,
LoginLink: loginLink,
LoginLinkWithCode: loginLinkWithCode,
ExpirationString: expirationString,
})
}
func (m *Module) SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device string, dateTime time.Time) error {
return send(ctx, m, dbConfig, address{
name: userFullName,
email: userEmail,
}, newLoginTemplate, &newLoginTemplateData{
IPAddress: ipAddress,
Country: country,
City: city,
Device: device,
DateTime: dateTime,
})
}
func (m *Module) SendAPIKeyExpiringSoon(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, firstName, apiKeyName string, expiresAt time.Time) error {
return send(ctx, m, dbConfig, address{
name: userFullName,
email: userEmail,
}, apiKeyExpiringSoonTemplate, &apiKeyExpiringSoonTemplateData{
Name: firstName,
ApiKeyName: apiKeyName,
ExpiresAt: expiresAt,
})
}
func send[V any](ctx context.Context, module *Module, dbConfig *appconfig.AppConfigModel, recipient address, tmpl template[V], data *V) error {
// Combine application metadata with message-specific data before rendering both MIME alternatives
templateData := &templateData[V]{
AppName: dbConfig.AppName.String(),
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
Data: data,
}
// Render the complete message before opening an SMTP connection so template failures never produce partial deliveries
text, html, err := renderBody(module, tmpl, templateData)
if err != nil {
return fmt.Errorf("prepare email body for '%s': %w", tmpl.path, err)
}
// Resolve SMTP settings for each delivery so application configuration changes take effect without restarting
emailerService, err := module.getEmailer(ctx, dbConfig)
if err != nil {
return fmt.Errorf("failed to configure emailer: %w", err)
}
// Send text and HTML together so clients can select the format they support
err = emailerService.SendEmail(ctx, emailer.EmailAddress{
Name: recipient.name,
Address: recipient.email,
}, tmpl.title(templateData), emailer.SendEmailMessage{
Text: text,
HTML: html,
})
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (m *Module) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) {
connString, err := smtpConnString(dbConfig)
if err != nil {
return nil, err
}
return emailer.NewEmailer(ctx, emailer.NewEmailerOpts{
ConnString: connString,
})
}
func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) {
// Build the SMTP authority from the configured endpoint and optional credentials
host := dbConfig.SmtpHost.String()
if host == "" {
return "", errors.New("SMTP host is not configured")
}
smtpURL := &url.URL{
Scheme: "smtp",
Host: host,
}
port := dbConfig.SmtpPort.String()
if port != "" {
smtpURL.Host = net.JoinHostPort(host, port)
}
smtpUser := dbConfig.SmtpUser.String()
smtpPassword := dbConfig.SmtpPassword.String()
if smtpUser != "" || smtpPassword != "" {
smtpURL.User = url.UserPassword(smtpUser, smtpPassword)
}
// Preserve sender identity and transport security settings in the connection string consumed by the emailer
tlsMode := dbConfig.SmtpTls.String()
if tlsMode == "" {
tlsMode = "none"
}
query := url.Values{}
query.Set("fromAddress", dbConfig.SmtpFrom.String())
query.Set("fromName", dbConfig.AppName.String())
query.Set("tls", tlsMode)
if dbConfig.SmtpSkipCertVerify.IsTrue() {
query.Set("insecureSkipVerify", "true")
}
smtpURL.RawQuery = query.Encode()
return smtpURL.String(), nil
}
func renderBody[V any](module *Module, tmpl template[V], data *templateData[V]) (text string, html string, err error) {
// Render both variants from the same data so the plain-text and HTML messages cannot diverge
textBuilder := &strings.Builder{}
err = module.textTemplates[tmpl.path].ExecuteTemplate(textBuilder, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err)
}
htmlBuilder := &strings.Builder{}
err = module.htmlTemplates[tmpl.path].ExecuteTemplate(htmlBuilder, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute HTML template: %w", err)
}
return textBuilder.String(), htmlBuilder.String(), nil
}
func prepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
templatePath := path.Join("email-templates", tmpl+"_text.tmpl")
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
textTemplates[tmpl] = parsedTemplate
}
return textTemplates, nil
}
func prepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
templatePath := path.Join("email-templates", tmpl+"_html.tmpl")
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
htmlTemplates[tmpl] = parsedTemplate
}
return htmlTemplates, nil
}