mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 03:46:29 +00:00
270 lines
7.9 KiB
Go
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
|
|
}
|