* 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>
163 lines
4.9 KiB
Go
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
|
|
}
|