feat(account): brand and harden the email pipeline
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the Message struct now carries a text + HTML body, TLS mode is chosen from the port (implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the synchronous send, and no client certificate is needed. Add a branded, image-free, mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a large readable code and an ignore-notice footer with a landing link. Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer link, never the request Host — anti-injection), required when a relay is configured. Fix the email-login address squat: ProvisionEmail creates the account flagged is_guest until the code is confirmed, so an abandoned login is reaped like any guest and its address freed; confirming (login or link) clears the flag. Seed the new account's language from the client, plumbed through the email-login request. The confirm deeplink, its transport surface and the send rate-limit land in follow-up work.
This commit is contained in:
@@ -3,23 +3,37 @@ package account
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"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 is offered
|
||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
||||
// and ignores it.
|
||||
// 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, to, subject, body string) error
|
||||
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. The Port selects the TLS
|
||||
// mode: smtpImplicitTLSPort (465) uses implicit TLS (SSL); any other port uses
|
||||
// mandatory STARTTLS.
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
@@ -28,8 +42,19 @@ type SMTPConfig struct {
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
||||
const (
|
||||
// smtpImplicitTLSPort is the port on which the relay is dialled with implicit
|
||||
// TLS (SSL). Any other port negotiates mandatory STARTTLS instead.
|
||||
smtpImplicitTLSPort = 465
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -39,29 +64,49 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
||||
return SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
var auth smtp.Auth
|
||||
if m.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
// 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)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
|
||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
||||
opts := []mail.Option{mail.WithTimeout(smtpDialTimeout)}
|
||||
if port == smtpImplicitTLSPort {
|
||||
opts = append(opts, mail.WithSSLPort(false))
|
||||
} else {
|
||||
opts = append(opts, mail.WithPort(port), 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
|
||||
}
|
||||
|
||||
// message renders a minimal RFC 5322 plain-text email.
|
||||
func message(from, to, subject, body string) []byte {
|
||||
return []byte("From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" + body + "\r\n")
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -74,11 +119,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
|
||||
return LogMailer{log: log}
|
||||
}
|
||||
|
||||
// Send logs the message at info level and reports success.
|
||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
// 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", to), zap.String("subject", subject), zap.String("body", body))
|
||||
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user