Files
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

163 lines
4.9 KiB
Go

package notify
import (
"context"
"errors"
"fmt"
"net/mail"
"net/url"
"strings"
"time"
"github.com/go-pkgz/email"
)
// SMTPParams contain settings for smtp server connection
type SMTPParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
HELOHost string // SMTP HELO/EHLO hostname
StartTLS bool // startTLS auth
InsecureSkipVerify bool // skip certificate verification
ContentType string // content type
Charset string // character set
LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com
Username string // username
Password string // password
TimeOut time.Duration // TCP connection timeout, the rest of the transaction is bound by the context of Send
}
// Email notifications client
type Email struct {
SMTPParams
sender *email.Sender
}
// NewEmail makes new Email object
func NewEmail(smtpParams SMTPParams) *Email {
var opts []email.Option
if smtpParams.Username != "" {
opts = append(opts, email.Auth(smtpParams.Username, smtpParams.Password))
}
if smtpParams.ContentType != "" {
opts = append(opts, email.ContentType(smtpParams.ContentType))
}
if smtpParams.Charset != "" {
opts = append(opts, email.Charset(smtpParams.Charset))
}
if smtpParams.LoginAuth {
opts = append(opts, email.LoginAuth())
}
if smtpParams.Port != 0 {
opts = append(opts, email.Port(smtpParams.Port))
}
if smtpParams.HELOHost != "" {
opts = append(opts, email.HELOHost(smtpParams.HELOHost))
}
if smtpParams.TimeOut != 0 {
opts = append(opts, email.TimeOut(smtpParams.TimeOut))
}
if smtpParams.TLS {
opts = append(opts, email.TLS(true))
}
if smtpParams.StartTLS {
opts = append(opts, email.STARTTLS(true))
}
if smtpParams.InsecureSkipVerify {
opts = append(opts, email.InsecureSkipVerify(true))
}
sender := email.NewSender(smtpParams.Host, opts...)
return &Email{sender: sender, SMTPParams: smtpParams}
}
// Send sends the message over Email, with "from", "subject" and "unsubscribeLink" parsed from destination field
// with "mailto:" schema.
// "unsubscribeLink" passed as a header, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
//
// Note: query parameter values in the mailto URL must be properly URL-encoded. In particular, email addresses
// containing "+" (e.g. "noreply+tag@example.com") must use "%2B" instead, otherwise "+" is interpreted as a space
// per standard URL query string parsing. Use url.QueryEscape for all parameter values.
//
// Example:
//
// - mailto:"John Wayne"<john@example.org>?subject=test-subj&from="Notifier"<notify@example.org>
// - mailto:addr1@example.org,addr2@example.org?subject=test-subj&from=notify@example.org&unsubscribeLink=http://example.org/unsubscribe
func (e *Email) Send(ctx context.Context, destination, text string) error {
emailParams, err := e.parseDestination(destination)
if err != nil {
return fmt.Errorf("problem parsing destination: %w", err)
}
// SendContext terminates the transaction when ctx is done, including the parts after the connection is made
err = e.sender.SendContext(ctx, text, emailParams)
if err != nil && ctx.Err() != nil && !errors.Is(err, ctx.Err()) {
// transaction was interrupted, report why on top of the error it failed with
return fmt.Errorf("%w: %w", ctx.Err(), err)
}
return err
}
// Schema returns schema prefix supported by this client
func (e *Email) Schema() string {
return "mailto"
}
// String representation of Email object
func (e *Email) String() string {
str := fmt.Sprintf("email: with username '%s' at server %s:%d", e.Username, e.Host, e.Port)
if e.TLS {
str += " with TLS"
}
if e.StartTLS {
str += " with StartTLS"
}
return str
}
// parses "mailto:" URL and returns email parameters
func (e *Email) parseDestination(destination string) (email.Params, error) {
// parse URL
u, err := url.Parse(destination)
if err != nil {
return email.Params{}, err
}
if u.Scheme != "mailto" {
return email.Params{}, fmt.Errorf("unsupported scheme %s, should be mailto", u.Scheme)
}
// parse destination address(es)
addresses, err := mail.ParseAddressList(u.Opaque)
if err != nil {
return email.Params{}, fmt.Errorf("problem parsing email recipients: %w", err)
}
destinations := []string{}
for _, addr := range addresses {
stringAddr := addr.String()
// in case of mailgun, correct RFC5322 address with <> yield 501 error, so we need to remove brackets
if strings.HasPrefix(stringAddr, "<") && strings.HasSuffix(stringAddr, ">") {
stringAddr = stringAddr[1 : len(stringAddr)-1]
}
destinations = append(destinations, stringAddr)
}
return email.Params{
From: u.Query().Get("from"),
To: destinations,
Subject: u.Query().Get("subject"),
UnsubscribeLink: u.Query().Get("unsubscribeLink"),
}, nil
}