Files
remark42/backend/vendor/github.com/go-pkgz/email/email.go
T
3f5b3cdd98 feat: add configurable SMTP HELO hostname (#2146)
* feat: add configurable SMTP HELO hostname

Allow the SMTP HELO/EHLO hostname to be configured separately from
the SMTP server hostname.

This is useful when the SMTP server requires clients to identify
themselves with a fully qualified hostname different from the server
address.

* chore: remove vendored dependency changes

* Bump go-pkgz/notify to v1.4.0 and document SMTP_HELO_HOST

The HELOHost field lands in go-pkgz/notify v1.4.0, so the branch needs the
bump to compile; v1.3.0 in master has no such field. The example module is
tidied alongside, as any change to backend/go.mod requires.

Documents the parameter in the parameters table and, separately, in the email
setup page: what it does, that leaving it unset keeps the previous `localhost`
greeting, and the case it exists for, a relay refusing the greeting under
Postfix `reject_non_fqdn_helo_hostname`.

Also records the current limit: verification emails for email authentication
go through go-pkgz/auth's own sender, which has no equivalent setting, so the
greeting there is unchanged.

* Bump go-pkgz/auth to v2.2.0 and apply SMTP_HELO_HOST to verification email

The verification email sender had no way to set the greeting, so a relay that
refuses the HELO would accept notifications and still reject sign-in emails.
EmailParams gains HELOHost in go-pkgz/auth v2.2.0, so the same SMTP_HELO_HOST
now drives both paths.

The example module is tidied alongside, as any change to backend/go.mod
requires.

---------

Co-authored-by: oli <someone@somewhere.tld>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2026-08-19 02:52:39 -05:00

546 lines
16 KiB
Go

// Package email provides email sender
package email
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
//go:generate moq -out mocks/smpt_client.go -pkg mocks -skip-ensure -fmt goimports . SMTPClient
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
// Sender implements email sender
type Sender struct {
smtpClient SMTPClient
logger Logger
host string // SMTP host
heloHost string // SMTP HELO/EHLO host
port int // SMTP port
contentType string // content type, optional. Will trigger MIME and Content-Type headers
tls bool // TLS auth
starttls bool // startTLS
insecureSkipVerify bool // insecure Skip Verify
smtpUserName string // username
smtpPassword string // password
authMethod authMethod // auth method
timeOut time.Duration
contentCharset string
timeNow func() time.Time
}
// Params contains all user-defined parameters to send emails
type Params struct {
From string // from email field
To []string // from email field
Subject string // email subject
UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
InReplyTo string // identifier for email group (category), used for email grouping
Attachments []string // attachments path
InlineImages []string // InlineImages images path
}
// Logger is used to log errors and debug messages
type Logger interface {
Logf(format string, args ...interface{})
}
// SMTPClient interface defines subset of net/smtp used by email client
type SMTPClient interface {
Mail(from string) error
Auth(auth smtp.Auth) error
Rcpt(to string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// NewSender creates email client with prepared smtp
func NewSender(smtpHost string, options ...Option) *Sender {
res := Sender{
smtpClient: nil,
logger: nopLogger{},
host: smtpHost,
port: 25,
contentType: `text/plain`,
tls: false,
insecureSkipVerify: false,
smtpUserName: "",
smtpPassword: "",
authMethod: authMethodPlain,
contentCharset: "UTF-8",
timeOut: time.Second * 30,
timeNow: time.Now,
}
for _, opt := range options {
opt(&res)
}
res.logger.Logf("[INFO] new email sender created with host: %s:%d, helo: %q, tls: %v, insecureSkipVerify: %v, username: %q, timeout: %v, "+
"content type: %q, charset: %q", smtpHost, res.port, res.effectiveHELOHost(),
res.tls, res.insecureSkipVerify, res.smtpUserName, res.timeOut, res.contentType, res.contentCharset)
return &res
}
// Send email with given text, with no cancellation and with TimeOut applied to the connection setup only.
// See SendContext for the details.
func (em *Sender) Send(text string, params Params) error {
return em.SendContext(context.Background(), text, params)
}
// SendContext sends email with given text and terminates the whole SMTP transaction as soon as ctx is done,
// including the greeting, the authentication and the message body transfer.
// If SMTPClient set with the SMTP option it will be used, if not - new smtp.Client on each send.
// Note that a client set that way owns its connection, so such a transaction can't be terminated in the middle.
// Always closes client on completion or failure.
func (em *Sender) SendContext(ctx context.Context, text string, params Params) error {
em.logger.Logf("[DEBUG] send %q to %v", text, params.To)
client := em.smtpClient // set by the SMTP option, nil when SendContext makes its own client below
var quit bool
defer func() {
if quit || client == nil { // quit set if Quit() call passed because it's closing connection as well.
return
}
if e := client.Close(); e != nil {
em.logger.Logf("[WARN] can't close smtp connection, %v", e)
}
}()
if err := ctx.Err(); err != nil { // nothing started yet, a client set with the SMTP option is closed by the defer
return err
}
if len(params.To) == 0 {
return errors.New("no recipients")
}
// message is built before the connection is made, this way a bad message doesn't reach the server at all
msg, err := em.buildMessage(text, params)
if err != nil {
return fmt.Errorf("can't make email message: %w", err)
}
if client == nil { // if client not set make new net/smtp
c, stop, e := em.client(ctx)
if e != nil {
return fmt.Errorf("failed to make smtp client: %w", e)
}
defer stop() // runs before the deferred close above, releasing the ctx watcher first
client = c
}
if auth := em.auth(); auth != nil {
if err = client.Auth(auth); err != nil {
return fmt.Errorf("failed to auth to smtp %s:%d, %w", em.host, em.port, err)
}
}
if err = client.Mail(extractEmailAddress(params.From)); err != nil {
return fmt.Errorf("bad from address %q: %w", params.From, err)
}
for _, rcpt := range params.To {
if err = client.Rcpt(extractEmailAddress(rcpt)); err != nil {
return fmt.Errorf("bad to address %q: %w", params.To, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("can't make email writer: %w", err)
}
if _, err = msg.WriteTo(writer); err != nil {
return fmt.Errorf("failed to send email body to %q: %w", params.To, err)
}
// closing the writer reports the final response to the DATA command, i.e. the actual delivery result
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to send email to %q: %w", params.To, err)
}
if err = client.Quit(); err != nil {
em.logger.Logf("[WARN] failed to send quit command to %s:%d, %v", em.host, em.port, err)
} else {
quit = true
}
return nil
}
// extractEmailAddress extracts the email address from a string that may contain a display name.
// For example, it converts `"John Doe" <john@example.com>` to `john@example.com`.
// If parsing fails, it returns the original string unchanged.
func extractEmailAddress(from string) string {
addr, err := mail.ParseAddress(strings.TrimSpace(from))
if err != nil {
return from
}
return addr.Address
}
func (em *Sender) String() string {
return fmt.Sprintf("smtp://%s:%d, helo:%q, auth:%v, tls:%v, starttls:%v, insecureSkipVerify:%v, timeout:%v, content-type:%q, charset:%q",
em.host, em.port, em.effectiveHELOHost(), em.smtpUserName != "", em.tls, em.starttls, em.insecureSkipVerify,
em.timeOut, em.contentType, em.contentCharset)
}
func (em *Sender) effectiveHELOHost() string {
if em.smtpClient != nil {
return "client-managed"
}
if em.heloHost == "" {
return "localhost"
}
return em.heloHost
}
// client makes smtp client with the connection bound to ctx: it is closed as soon as ctx is done,
// which is the only way to interrupt net/smtp calls as they take no context.
// Returned stop function releases that binding and has to be called when the client is not needed anymore.
func (em *Sender) client(ctx context.Context) (c *smtp.Client, stop func(), err error) {
srvAddress := net.JoinHostPort(em.host, strconv.Itoa(em.port))
// #nosec G402
tlsConf := &tls.Config{
InsecureSkipVerify: em.insecureSkipVerify, // #nosec G402
ServerName: em.host,
MinVersion: tls.VersionTLS12,
}
dialer := &net.Dialer{Timeout: em.timeOut}
var conn net.Conn
if em.tls {
if conn, err = (&tls.Dialer{NetDialer: dialer, Config: tlsConf}).DialContext(ctx, "tcp", srvAddress); err != nil {
return nil, nil, fmt.Errorf("failed to dial smtp tls to %s: %w", srvAddress, err)
}
} else {
if conn, err = dialer.DialContext(ctx, "tcp", srvAddress); err != nil {
return nil, nil, fmt.Errorf("timeout connecting to %s: %w", srvAddress, err)
}
}
// closing the connection is the only way to interrupt net/smtp calls, as they take no context
watchDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = conn.Close()
case <-watchDone:
}
}()
var stopOnce sync.Once
stop = func() { stopOnce.Do(func() { close(watchDone) }) }
if deadline, ok := ctx.Deadline(); ok {
if e := conn.SetDeadline(deadline); e != nil {
em.logger.Logf("[WARN] can't set deadline on smtp connection to %s, %v", srvAddress, e)
}
}
if c, err = smtp.NewClient(conn, em.host); err != nil {
stop()
_ = conn.Close()
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, nil, fmt.Errorf("failed to make smtp client for %s: %w", srvAddress, ctxErr)
}
if em.tls {
return nil, nil, fmt.Errorf("failed to make smtp client for %s: %w", srvAddress, err)
}
return nil, nil, fmt.Errorf("failed to dial: %w", err)
}
if err = em.hello(c); err != nil {
stop()
_ = c.Close()
return nil, nil, err
}
if !em.tls && em.starttls {
if err = c.StartTLS(tlsConf); err != nil {
stop()
_ = c.Close()
return nil, nil, fmt.Errorf("failed to start tls: %w", err)
}
}
return c, stop, nil
}
func (em *Sender) hello(client *smtp.Client) error {
if em.heloHost == "" {
return nil
}
if err := client.Hello(em.heloHost); err != nil {
return fmt.Errorf("failed to send SMTP greeting: %w", err)
}
return nil
}
// auth returns an smtp.Auth that implements SMTP authentication mechanism
// depends on Sender settings.
func (em *Sender) auth() smtp.Auth {
if em.smtpUserName == "" || em.smtpPassword == "" {
return nil // no auth
}
if em.authMethod == authMethodLogin {
return newLoginAuth(em.smtpUserName, em.smtpPassword, em.host)
}
return smtp.PlainAuth("", em.smtpUserName, em.smtpPassword, em.host)
}
// validateHeaders rejects user-provided values which would break out of the header they are put into.
// CR and LF allow injecting arbitrary headers and message body, i.e. sending a different email than the caller intended.
func (params Params) validateHeaders() error {
check := func(name, value string) error {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("invalid %s header value %q: contains CR or LF", name, value)
}
return nil
}
for _, h := range [][2]string{
{"From", params.From},
{"Subject", params.Subject},
{"List-Unsubscribe", params.UnsubscribeLink},
{"In-reply-to", params.InReplyTo},
} {
if err := check(h[0], h[1]); err != nil {
return err
}
}
for _, to := range params.To {
if err := check("To", to); err != nil {
return err
}
}
return nil
}
// buildMessage makes the complete message, headers and body, in a single buffer the caller sends as is
func (em *Sender) buildMessage(text string, params Params) (*bytes.Buffer, error) {
if err := params.validateHeaders(); err != nil {
return nil, err
}
buff := &bytes.Buffer{}
addHeader := func(h, v string) {
fmt.Fprintf(buff, "%s: %s\n", h, v)
}
// body writers are made upfront because the boundaries they pick are needed in the headers,
// they write nothing until used, i.e. after all the headers are in the buffer
qp := quotedprintable.NewWriter(buff)
mpMixed := multipart.NewWriter(buff)
boundaryMixed := mpMixed.Boundary()
mpRelated := multipart.NewWriter(buff)
boundaryRelated := mpRelated.Boundary()
addHeader("From", params.From)
addHeader("To", strings.Join(params.To, ","))
addHeader("Subject", mime.BEncoding.Encode("utf-8", params.Subject))
if params.UnsubscribeLink != "" {
addHeader("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
addHeader("List-Unsubscribe", "<"+params.UnsubscribeLink+">")
}
if params.InReplyTo != "" {
addHeader("In-reply-to", "<"+params.InReplyTo+">")
}
withAttachments := len(params.Attachments) > 0
withInlineImg := len(params.InlineImages) > 0
if em.contentType != "" || withAttachments || withInlineImg {
addHeader("MIME-version", "1.0")
}
addHeader("Date", em.timeNow().Format(time.RFC1123Z))
if withAttachments {
addHeader("Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q\r\n\r\n%s\r",
boundaryMixed, "--"+boundaryMixed))
}
if withInlineImg {
addHeader("Content-Type", fmt.Sprintf("multipart/related; boundary=%q\r\n\r\n%s\r",
boundaryRelated, "--"+boundaryRelated))
}
if em.contentType != "" {
addHeader("Content-Transfer-Encoding", "quoted-printable")
addHeader("Content-Type", fmt.Sprintf("%s; charset=%q", em.contentType, em.contentCharset))
}
buff.WriteString("\n") // empty line between the headers and the body
if err := em.writeBody(qp, text); err != nil {
return nil, fmt.Errorf("failed to write body: %w", err)
}
if withInlineImg {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpRelated, params.InlineImages, "inline"); err != nil {
return nil, fmt.Errorf("failed to write inline images: %w", err)
}
}
if withAttachments {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpMixed, params.Attachments, "attachment"); err != nil {
return nil, fmt.Errorf("failed to write attachments: %w", err)
}
}
return buff, nil
}
func (em *Sender) writeBody(wc io.WriteCloser, text string) error {
if _, err := wc.Write([]byte(text)); err != nil {
return err
}
if err := wc.Close(); err != nil {
return err
}
return nil
}
func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition string) error {
for _, attachment := range files {
if err := em.writeFile(mp, attachment, disposition); err != nil {
return err
}
}
if err := mp.Close(); err != nil {
return err
}
return nil
}
// writeFile adds a single file as a mime part, the file is closed on every return path
func (em *Sender) writeFile(mp *multipart.Writer, attachment, disposition string) (err error) {
file, err := os.Open(filepath.Clean(attachment))
if err != nil {
return err
}
defer func() {
if e := file.Close(); e != nil && err == nil {
err = e
}
}()
// we need first 512 bytes to detect file type, an empty file is fine and detected as plain text
fTypeBuff := make([]byte, 512)
n, err := file.Read(fTypeBuff)
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("failed to read file type %q: %w", attachment, err)
}
fTypeBuff = fTypeBuff[:n] // file can be shorter than the buffer
fName := filepath.Base(attachment)
// CR and LF are legal in file names but would terminate the header the name goes into
if strings.ContainsAny(fName, "\r\n") {
return fmt.Errorf("invalid file name %q: contains CR or LF", attachment)
}
// the detected type is always parseable, it comes from a fixed set of sniffed types
contentType, ctParams, _ := mime.ParseMediaType(http.DetectContentType(fTypeBuff))
params := map[string]string{"name": fName}
for k, v := range ctParams { // carries the charset of the text types over
params[k] = v
}
// mime formatting quotes and encodes the file name, plain interpolation would let it break out of the header
header := textproto.MIMEHeader{}
header.Set("Content-Type", mime.FormatMediaType(contentType, params))
header.Set("Content-Transfer-Encoding", "base64")
switch disposition {
case "attachment", "inline":
header.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": fName}))
}
if disposition == "inline" {
header.Set("Content-ID", fmt.Sprintf("<%s>", fName))
}
writer, err := mp.CreatePart(header)
if err != nil {
return err
}
// set reader offset at the beginning of the file because we read first 512 bytes
if _, err = file.Seek(0, io.SeekStart); err != nil {
return err
}
encoder := base64.NewEncoder(base64.StdEncoding, &lineWrapper{w: writer, limit: base64LineLimit})
if _, err = io.Copy(encoder, file); err != nil {
return err
}
return encoder.Close()
}
// base64LineLimit is the maximum line length for base64 encoded mime parts, set by RFC 2045
const base64LineLimit = 76
// crlf is shared by the line wrapper to keep it from allocating a separator for every line
var crlf = []byte("\r\n")
// lineWrapper breaks the stream written to it into lines of limit characters, separated by CRLF.
// Base64 encoders produce a single unbroken line, which mime doesn't allow and strict relays reject.
type lineWrapper struct {
w io.Writer
limit int
n int // characters already written to the current line
}
func (lw *lineWrapper) Write(p []byte) (int, error) {
written := 0
for len(p) > 0 {
if lw.n == lw.limit {
if _, err := lw.w.Write(crlf); err != nil {
return written, err
}
lw.n = 0
}
size := lw.limit - lw.n
if size > len(p) {
size = len(p)
}
n, err := lw.w.Write(p[:size])
written += n
lw.n += n
if err != nil {
return written, err
}
p = p[size:]
}
return written, nil
}
type nopLogger struct{}
func (nopLogger) Logf(_ string, _ ...interface{}) {}