owpengram-server/internal/otpdelivery/smtp/sender.go
Astra 2e96d9490e smtp: add Date and Message-ID headers to outgoing mail
RFC 5322 requires both headers. Date uses RFC1123Z formatting; Message-ID
is a random 16-byte token scoped to the sending domain parsed from From.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 17:39:55 +01:00

205 lines
6.1 KiB
Go

package smtp
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
stdmail "net/mail"
stdsmtp "net/smtp"
"strings"
"time"
"telesrv/internal/otpdelivery"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
FromName string
// AppName is the product name shown in the email subject/body (e.g.
// "Your <AppName> login code"). Defaults to "telesrv" if empty, matching
// this package's other defaults — callers should pass the same brand
// name used elsewhere (e.g. Config.PublicAppName), or codes will read as
// coming from "telesrv" regardless of the operator's own branding.
AppName string
TLSMode string
Timeout time.Duration
}
type Sender struct {
cfg Config
}
func New(cfg Config) *Sender {
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
cfg.TLSMode = strings.ToLower(strings.TrimSpace(cfg.TLSMode))
if cfg.TLSMode == "" {
cfg.TLSMode = "starttls"
}
if strings.TrimSpace(cfg.From) == "" {
cfg.From = cfg.Username
}
if strings.TrimSpace(cfg.AppName) == "" {
cfg.AppName = "telesrv"
}
return &Sender{cfg: cfg}
}
func (s *Sender) Deliver(ctx context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
if err := req.Validate(time.Now()); err != nil {
return otpdelivery.Result{}, err
}
if req.Channel != otpdelivery.ChannelEmail {
return otpdelivery.Result{}, fmt.Errorf("smtp cannot deliver channel %q", req.Channel)
}
ttl := time.Until(req.ExpiresAt)
subject, body := emailContent(s.cfg.AppName, req.Code, ttl)
if err := s.send(ctx, req.Recipient, subject, body); err != nil {
return otpdelivery.Result{}, err
}
return otpdelivery.Result{}, nil
}
func (s *Sender) send(ctx context.Context, to, subject, body string) error {
if strings.TrimSpace(s.cfg.Host) == "" {
return fmt.Errorf("smtp host is empty")
}
from := strings.TrimSpace(s.cfg.From)
if from == "" {
return fmt.Errorf("smtp from is empty")
}
if _, err := stdmail.ParseAddress(to); err != nil {
return fmt.Errorf("parse recipient: %w", err)
}
fromAddr := from
if s.cfg.FromName != "" {
fromAddr = (&stdmail.Address{Name: s.cfg.FromName, Address: from}).String()
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
var d net.Dialer
d.Timeout = s.cfg.Timeout
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer conn.Close()
mode := strings.ToLower(strings.TrimSpace(s.cfg.TLSMode))
var c *stdsmtp.Client
if mode == "tls" {
tlsConn := tls.Client(conn, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return fmt.Errorf("smtp tls handshake: %w", err)
}
c, err = stdsmtp.NewClient(tlsConn, s.cfg.Host)
} else {
c, err = stdsmtp.NewClient(conn, s.cfg.Host)
}
if err != nil {
return fmt.Errorf("new smtp client: %w", err)
}
defer c.Close()
if mode == "starttls" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
} else {
return fmt.Errorf("smtp server does not support STARTTLS")
}
}
if s.cfg.Username != "" {
if err := c.Auth(stdsmtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
msg := buildMessage(fromAddr, to, subject, body)
if _, err := w.Write(msg); err != nil {
_ = w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return c.Quit()
}
func buildMessage(from, to, subject, body string) []byte {
var b bytes.Buffer
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + generateMessageID(from) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return b.Bytes()
}
// generateMessageID builds a Message-ID header value (RFC 5322 3.6.4), using
// the sending domain parsed out of the From address and a random token so
// each message gets a unique id even under concurrent sends.
func generateMessageID(from string) string {
domain := "localhost"
if addr, err := stdmail.ParseAddress(from); err == nil {
if i := strings.LastIndex(addr.Address, "@"); i >= 0 {
domain = addr.Address[i+1:]
}
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
// crypto/rand failing is effectively unheard of, but fall back to a
// time-based token rather than emit a non-unique Message-ID.
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
}
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(raw[:]), domain)
}
// emailContent builds the login-code email subject/body, branded with the
// operator's configured product name (Config.AppName) instead of the
// package's internal "telesrv" fallback.
func emailContent(appName, code string, ttl time.Duration) (subject, body string) {
subject = fmt.Sprintf("Your %s login code", appName)
body = fmt.Sprintf("Your %s login code is %s.\n\nThis code expires in %s. If you did not request it, ignore this email.\n", appName, code, humanTTL(ttl))
return subject, body
}
func humanTTL(ttl time.Duration) string {
if ttl <= 0 {
return "a short time"
}
// Network and processing time can shave sub-second precision off an exact
// configured TTL. Round up so a five-minute code is not rendered as 4m59s.
ttl = ttl.Round(time.Second)
if ttl%time.Minute == 0 {
minutes := int(ttl / time.Minute)
if minutes == 1 {
return "1 minute"
}
return fmt.Sprintf("%d minutes", minutes)
}
return ttl.String()
}