diff --git a/backend/go.mod b/backend/go.mod index ba0217bb..aabada2d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 9d6712f8..d61bba9e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 7dffa08c..19e671f9 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -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://:@:?fromAddress=
&fromName=&tls=&insecureSkipVerify= +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 }