c702f1bdac
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127), which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS (ssl|starttls) — empty still derives the mode from the port (implicit on 465, else STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it (Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested.
151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wneessen/go-mail"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Message is a transactional email to send through a Mailer. Text is the
|
|
// required plain-text body and doubles as the multipart/alternative fallback;
|
|
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
|
type Message struct {
|
|
To string
|
|
Subject string
|
|
Text string
|
|
HTML string
|
|
}
|
|
|
|
// Mailer delivers a transactional email. It is the seam behind which the email
|
|
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
|
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
|
|
// delivery and is honoured by the SMTP implementation.
|
|
type Mailer interface {
|
|
Send(ctx context.Context, msg Message) error
|
|
}
|
|
|
|
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
|
// instead, so a deployment without a relay still runs (the code lands in the log).
|
|
// TLS is always used and no client certificate is required — only the server
|
|
// certificate is validated against the system roots.
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port string
|
|
Username string
|
|
Password string
|
|
From string
|
|
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
|
|
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
|
|
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
|
|
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
|
|
TLS string
|
|
}
|
|
|
|
const (
|
|
// SMTP transport-security modes for SMTPConfig.TLS.
|
|
smtpTLSImplicit = "ssl"
|
|
smtpTLSSTARTTLS = "starttls"
|
|
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
|
|
// is synchronous on the request path, so an unreachable relay must fail fast
|
|
// rather than hold the request open.
|
|
smtpDialTimeout = 15 * time.Second
|
|
)
|
|
|
|
// tlsMode resolves the transport-security mode for the relay: the explicitly
|
|
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
|
|
// port 465 and STARTTLS on any other port.
|
|
func (cfg SMTPConfig) tlsMode(port int) string {
|
|
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
|
|
case smtpTLSImplicit, "tls":
|
|
return smtpTLSImplicit
|
|
case smtpTLSSTARTTLS:
|
|
return smtpTLSSTARTTLS
|
|
}
|
|
if port == 465 {
|
|
return smtpTLSImplicit
|
|
}
|
|
return smtpTLSSTARTTLS
|
|
}
|
|
|
|
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
|
|
// set it authenticates, auto-discovering the strongest mechanism the relay
|
|
// advertises; otherwise it relays unauthenticated.
|
|
type SMTPMailer struct {
|
|
cfg SMTPConfig
|
|
}
|
|
|
|
// NewSMTPMailer constructs an SMTPMailer for cfg.
|
|
func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
|
return SMTPMailer{cfg: cfg}
|
|
}
|
|
|
|
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
|
|
// is set the message is multipart/alternative (plain text plus HTML); otherwise
|
|
// it is plain text only.
|
|
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
|
port, err := strconv.Atoi(m.cfg.Port)
|
|
if err != nil {
|
|
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
|
|
}
|
|
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
|
|
if m.cfg.tlsMode(port) == smtpTLSImplicit {
|
|
opts = append(opts, mail.WithSSL())
|
|
} else {
|
|
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
|
|
}
|
|
if m.cfg.Username != "" {
|
|
opts = append(opts,
|
|
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
|
|
mail.WithUsername(m.cfg.Username),
|
|
mail.WithPassword(m.cfg.Password),
|
|
)
|
|
}
|
|
client, err := mail.NewClient(m.cfg.Host, opts...)
|
|
if err != nil {
|
|
return fmt.Errorf("account: build mail client: %w", err)
|
|
}
|
|
out := mail.NewMsg()
|
|
if err := out.From(m.cfg.From); err != nil {
|
|
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
|
|
}
|
|
if err := out.To(msg.To); err != nil {
|
|
return fmt.Errorf("account: set To %q: %w", msg.To, err)
|
|
}
|
|
out.Subject(msg.Subject)
|
|
out.SetBodyString(mail.TypeTextPlain, msg.Text)
|
|
if msg.HTML != "" {
|
|
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
|
|
}
|
|
if err := client.DialAndSendWithContext(ctx, out); err != nil {
|
|
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LogMailer logs the message instead of sending it. It is the default when no
|
|
// SMTP relay is configured and is intended for development only: it logs the body,
|
|
// which carries the confirm-code, so it must not be used in production.
|
|
type LogMailer struct {
|
|
log *zap.Logger
|
|
}
|
|
|
|
// NewLogMailer constructs a LogMailer that logs through log.
|
|
func NewLogMailer(log *zap.Logger) LogMailer {
|
|
return LogMailer{log: log}
|
|
}
|
|
|
|
// Send logs the message at info level and reports success. It logs the plain-text
|
|
// body only (which carries the confirm-code); the HTML alternative is omitted.
|
|
func (m LogMailer) Send(_ context.Context, msg Message) error {
|
|
if m.log != nil {
|
|
m.log.Info("email not sent (log mailer)",
|
|
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
|
|
}
|
|
return nil
|
|
}
|