Files
backend_fiesta/utils/mail.go
2026-09-29 16:53:21 +05:30

155 lines
5.4 KiB
Go

package utils
import (
"crypto/tls"
"fmt"
"net/mail"
"net/smtp"
"strings"
"time"
"nearle/config"
)
// Sending mail.
//
// ── Why the standard library and not a client ───────────────────────────────
//
// `net/smtp` is enough for what this sends: a handful of invitations a day, one
// at a time, to addresses a person typed. Every transactional provider speaks
// SMTP — SES, SendGrid, Resend, a company relay — so the choice of provider is
// a host and a password rather than a dependency and a rewrite. Adding an SDK
// would buy templating and analytics that nothing here wants yet, in exchange
// for a supply chain.
//
// ── Nil is a configuration, not a failure ───────────────────────────────────
//
// `NewMailer` returns nil when no host is set, matching `NewChat` and
// `NewEmbedder`. A deployment without mail still boots, still onboards tenants,
// and records the invitation as unsent. The alternative — refusing to start —
// would make mail a hard dependency of creating a merchant, which it is not.
// Mailer sends one message.
//
// One method, on purpose. Everything this server sends is a short transactional
// note to one recipient; a richer interface would be describing a mail product
// nobody asked for.
type Mailer interface {
Send(to, subject, body string) error
}
// mailTimeout bounds a send.
//
// Onboarding waits on this, so it cannot be generous. A relay that has not
// answered in ten seconds is not going to, and the invitation is better
// recorded as unsent — and resent — than holding a tenant creation open.
const mailTimeout = 10 * time.Second
type smtpMailer struct {
cfg config.MailConfig
}
// NewMailer builds a sender, or nil when none is configured.
func NewMailer(cfg config.MailConfig) (Mailer, error) {
if !cfg.Enabled() {
return nil, nil
}
if _, err := mail.ParseAddress(cfg.FromAddress); err != nil {
// Caught here rather than at the first send, because a malformed
// sender fails every message and should stop the deployment being
// described as able to send.
return nil, fmt.Errorf("MAIL_FROM is not a valid address: %w", err)
}
return &smtpMailer{cfg: cfg}, nil
}
func (m *smtpMailer) Send(to, subject, body string) error {
recipient, err := mail.ParseAddress(strings.TrimSpace(to))
if err != nil {
// A merchant's primary email is typed by whoever onboarded them, so a
// typo here is ordinary. Named clearly, because the fix is to correct
// the tenant's record and resend.
return fmt.Errorf("%q is not a valid email address", to)
}
from := mail.Address{Name: m.cfg.FromName, Address: m.cfg.FromAddress}
message := buildMessage(from, *recipient, subject, body)
client, err := m.dial()
if err != nil {
return err
}
defer client.Close()
if m.cfg.Username != "" {
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("the mail server refused our credentials: %w", err)
}
}
if err := client.Mail(m.cfg.FromAddress); err != nil {
return fmt.Errorf("the mail server refused the sender: %w", err)
}
if err := client.Rcpt(recipient.Address); err != nil {
return fmt.Errorf("the mail server refused %s: %w", recipient.Address, err)
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write([]byte(message)); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
// dial opens a connection, upgrading to TLS where the server offers it.
//
// STARTTLS rather than implicit TLS, because 587 is the submission port every
// provider documents and it begins in the clear. The upgrade is attempted
// whenever the server advertises it and the connection is abandoned if it fails
// — an invitation is a credential, and sending one over plaintext to a server
// that offered encryption would be choosing not to use it.
func (m *smtpMailer) dial() (*smtp.Client, error) {
client, err := smtp.Dial(m.cfg.Address())
if err != nil {
return nil, fmt.Errorf("could not reach the mail server at %s: %w", m.cfg.Address(), err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
client.Close()
return nil, fmt.Errorf("the mail server offered TLS and then refused it: %w", err)
}
}
return client, nil
}
// buildMessage assembles the wire format.
//
// Headers then a blank line then the body, with CRLF line endings — SMTP is
// specified in terms of CRLF and some servers reject bare newlines, which
// presents as mail that works locally and vanishes in production.
func buildMessage(from, to mail.Address, subject, body string) string {
var b strings.Builder
b.WriteString("From: " + from.String() + "\r\n")
b.WriteString("To: " + to.String() + "\r\n")
// Folded and encoded by `mail.Address`'s rules for the addresses; the
// subject is plain ASCII by construction in this codebase, so it needs no
// MIME word encoding. If a subject ever carries a merchant's name, that
// changes and this needs `mime.QEncoding`.
b.WriteString("Subject: " + strings.ReplaceAll(subject, "\n", " ") + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
return b.String()
}