mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 04:03:47 +03:00
chore: use go-kit for sending emails (#1565)
This commit is contained in:
committed by
GitHub
parent
0d2624db9c
commit
f68ad42618
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
// Configure the emailer from the current app config
|
||||
e, err := srv.getEmailer(ctx, dbConfig)
|
||||
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()
|
||||
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
|
||||
|
||||
// Send the HELO command
|
||||
if err := srv.sendHelloCommand(client); err != nil {
|
||||
return nil, fmt.Errorf("failed to send HELO command: %w", err)
|
||||
return emailer.NewEmailer(ctx, emailer.NewEmailerOpts{
|
||||
ConnString: connString,
|
||||
})
|
||||
}
|
||||
|
||||
// Set up the authentication if user or password are set
|
||||
// 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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
// TLS values from config: none, starttls, tls
|
||||
tlsMode := dbConfig.SmtpTls.Value
|
||||
if tlsMode == "" {
|
||||
tlsMode = "none"
|
||||
}
|
||||
|
||||
return client, err
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user