chore: use go-kit for sending emails (#1565)

This commit is contained in:
Alessandro (Ale) Segala
2026-07-05 13:10:11 -07:00
committed by GitHub
parent 0d2624db9c
commit f68ad42618
3 changed files with 70 additions and 205 deletions

View File

@@ -14,8 +14,6 @@ require (
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
@@ -28,7 +26,7 @@ require (
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/italypaleale/francis v0.1.0-beta.6
github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1

View File

@@ -133,10 +133,6 @@ github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -249,8 +245,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/italypaleale/francis v0.1.0-beta.6 h1:88XGnFMwQEMsGSJvI9vrGGvp//WKipXH8YPnfNHDStQ=
github.com/italypaleale/francis v0.1.0-beta.6/go.mod h1:3QQ1iSRGzvxJZ5gfVlVR1RyPDUAgzyN+m2vHeeR7OVs=
github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f h1:cyvaGWNg9AisrfG2guQspCawM6HhLaqbpHAppsePyH4=
github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf h1:8tk4010URtPgVqwZb7CDiNsuQI71qc8t7VSS4IaxoTY=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
github.com/italypaleale/go-sql-utils v0.2.4-0.20260702021114-e1dfdfb67d0e h1:HDhmMkWAlLy/Elr/+T7diAZgAI+sDY2hYAyIAyefUYk=
github.com/italypaleale/go-sql-utils v0.2.4-0.20260702021114-e1dfdfb67d0e/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=

View File

@@ -1,24 +1,16 @@
package service
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
htemplate "html/template"
"io"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"os"
"net"
"net/url"
"strings"
ttemplate "text/template"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid"
"github.com/italypaleale/go-kit/emailer"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -82,224 +74,103 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
Data: tData,
}
body, boundary, err := prepareBody(srv, template, data)
// Render the text and HTML bodies
text, html, err := renderBody(srv, template, data)
if err != nil {
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
}
// Construct the email message
c := email.NewComposer()
c.AddHeader("Subject", template.Title(data))
c.AddAddressHeader("From", []email.Address{
{
Email: dbConfig.SmtpFrom.Value,
Name: dbConfig.AppName.Value,
},
})
c.AddAddressHeader("To", []email.Address{toEmail})
c.AddHeaderRaw("Content-Type",
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
)
c.AddHeader("MIME-Version", "1.0")
c.AddHeader("Date", time.Now().Format(time.RFC1123Z))
// to create a message-id, we need the FQDN of the sending server, but that may be a docker hostname or localhost
// so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname
fromAddress := dbConfig.SmtpFrom.Value
domain := ""
if strings.Contains(fromAddress, "@") {
domain = strings.Split(fromAddress, "@")[1]
} else {
hostname, err := os.Hostname()
if err != nil {
// can that happen? we just give up
return fmt.Errorf("failed to get own hostname: %w", err)
} else {
domain = hostname
}
}
c.AddHeader("Message-ID", "<"+uuid.New().String()+"@"+domain+">")
c.Body(body)
// Check if the context is still valid before attemtping to connect
// We need to do this because the smtp library doesn't have context support
select {
case <-ctx.Done():
return ctx.Err()
default:
// All good
}
// Connect to the SMTP server
client, err := srv.getSmtpClient()
// Configure the emailer from the current app config
e, err := srv.getEmailer(ctx, dbConfig)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer client.Close()
// Check if the context is still valid before sending the email
select {
case <-ctx.Done():
return ctx.Err()
default:
// All good
return fmt.Errorf("failed to configure emailer: %w", err)
}
// Send the email
err = srv.sendEmailContent(client, toEmail, c)
err = e.SendEmail(ctx, emailer.EmailAddress{
Name: toEmail.Name,
Address: toEmail.Email,
}, template.Title(data), emailer.SendEmailMessage{
Text: text,
HTML: html,
})
if err != nil {
return fmt.Errorf("send email content: %w", err)
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
dbConfig := srv.appConfigService.GetDbConfig()
port := dbConfig.SmtpPort.Value
smtpAddress := dbConfig.SmtpHost.Value + ":" + port
tlsConfig := &tls.Config{
InsecureSkipVerify: dbConfig.SmtpSkipCertVerify.IsTrue(), //nolint:gosec
ServerName: dbConfig.SmtpHost.Value,
}
// Connect to the SMTP server based on TLS setting
switch dbConfig.SmtpTls.Value {
case "none":
client, err = smtp.Dial(smtpAddress)
case "tls":
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
case "starttls":
client, err = smtp.DialStartTLS(
smtpAddress,
tlsConfig,
)
default:
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", dbConfig.SmtpTls.Value)
}
// getEmailer builds an emailer.Emailer from the current app config.
func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConfig) (emailer.Emailer, error) {
// We support SMTP only (for now)
connString, err := smtpConnString(dbConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
return nil, err
}
client.CommandTimeout = 10 * time.Second
return emailer.NewEmailer(ctx, emailer.NewEmailerOpts{
ConnString: connString,
})
}
// Send the HELO command
if err := srv.sendHelloCommand(client); err != nil {
return nil, fmt.Errorf("failed to send HELO command: %w", err)
// smtpConnString builds the SMTP connection string that go-kit's emailer expects:
// smtp://<username>:<password>@<host>:<port>?fromAddress=<address>&fromName=<name>&tls=<none|starttls|tls>&insecureSkipVerify=<true|false>
func smtpConnString(dbConfig *model.AppConfig) (string, error) {
host := dbConfig.SmtpHost.Value
if host == "" {
return "", errors.New("SMTP host is not configured")
}
// Set up the authentication if user or password are set
u := &url.URL{
Scheme: "smtp",
Host: host,
}
port := dbConfig.SmtpPort.Value
if port != "" {
u.Host = net.JoinHostPort(host, port)
}
// Include credentials when set
smtpUser := dbConfig.SmtpUser.Value
smtpPassword := dbConfig.SmtpPassword.Value
if smtpUser != "" || smtpPassword != "" {
// Authenticate with plain auth
auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
if err := client.Auth(auth); err != nil {
// If the server does not support plain auth, try login auth
var smtpErr *smtp.SMTPError
ok := errors.As(err, &smtpErr)
if ok && smtpErr.Code == smtp.ErrAuthUnknownMechanism.Code {
auth = sasl.NewLoginClient(smtpUser, smtpPassword)
err = client.Auth(auth)
}
// Both plain and login auth failed
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
}
u.User = url.UserPassword(smtpUser, smtpPassword)
}
return client, err
// TLS values from config: none, starttls, tls
tlsMode := dbConfig.SmtpTls.Value
if tlsMode == "" {
tlsMode = "none"
}
// Build the query string args
q := url.Values{}
q.Set("fromAddress", dbConfig.SmtpFrom.Value)
q.Set("fromName", dbConfig.AppName.Value)
q.Set("tls", tlsMode)
if dbConfig.SmtpSkipCertVerify.IsTrue() {
q.Set("insecureSkipVerify", "true")
}
u.RawQuery = q.Encode()
// Return the connection string
return u.String(), nil
}
func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
hostname, err := os.Hostname()
if err == nil {
if err := client.Hello(hostname); err != nil {
return err
}
}
return nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
// Set the sender
if err := client.Mail(srv.appConfigService.GetDbConfig().SmtpFrom.Value, nil); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
// Set the recipient
if err := client.Rcpt(toEmail.Email, nil); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
// Get a writer to write the email data
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
// Write the email content
_, err = io.Copy(w, strings.NewReader(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
// Close the writer
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
}
func prepareBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (string, string, error) {
body := bytes.NewBuffer(nil)
mpart := multipart.NewWriter(body)
// prepare text part
var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain; charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader)
if err != nil {
return "", "", fmt.Errorf("create text part: %w", err)
}
textQp := quotedprintable.NewWriter(textPart)
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textQp, "root", data)
// renderBody renders the text and HTML templates for the message into strings
func renderBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (text string, html string, err error) {
textBuilder := &strings.Builder{}
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textBuilder, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err)
}
textQp.Close()
var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil {
return "", "", fmt.Errorf("create html part: %w", err)
}
htmlQp := quotedprintable.NewWriter(htmlPart)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
htmlBuilder := &strings.Builder{}
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlBuilder, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err)
}
htmlQp.Close()
err = mpart.Close()
if err != nil {
return "", "", fmt.Errorf("close multipart: %w", err)
}
return body.String(), mpart.Boundary(), nil
return textBuilder.String(), htmlBuilder.String(), nil
}