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

107 lines
4.3 KiB
Go

package config
import (
"fmt"
"strconv"
"strings"
)
// Sending email.
//
// ── Why this exists at all ──────────────────────────────────────────────────
//
// A newly onboarded merchant's admin account arrives with no password, and the
// only safe way to let them set one is a signed invitation sent to the primary
// email they gave us. Until this, the server could not send email: no library,
// no configuration, and `NotifyUser` is Firebase push rather than mail.
//
// ── Shaped like AssistantConfig, for the same reasons ───────────────────────
//
// Unconfigured is a deployment choice and not a fault, so `Enabled` reports it
// and `Why` says which variable is missing. A server with no mail still boots
// and still onboards tenants — the invitation is recorded as unsent rather than
// failing the creation, because a tenant that exists and cannot be reached is
// recoverable and a tenant that was rolled back by a mail outage is confusing.
type MailConfig struct {
// SMTP, because it is the one protocol every provider speaks. A transactional
// service (SES, SendGrid, Resend) is reached the same way, with its own host
// and an API key as the password — so choosing one later is configuration
// rather than code.
Host string
Port int
Username string
Password string
// Who the invitation appears to come from. Separate from the username
// because most providers authenticate as one identity and send as another,
// and using the login as the From address is how mail ends up in spam.
FromAddress string
FromName string
// Where the invitation link points. The merchant console, always — a
// merchant sets their password there and nowhere else — and a build
// variable rather than a constant because the site can move.
ConsoleURL string
}
func (m MailConfig) Enabled() bool { return m.Why() == "" }
// Why says what is missing, or "" when mail can be sent.
//
// A sentence rather than a bool. "Off" is the same answer for five different
// mistakes, and the difference between "we have not set this up" and "somebody
// misspelled a variable" is invisible from outside — which is exactly how the
// assistant sat switched off for two days.
func (m MailConfig) Why() string {
if strings.TrimSpace(m.Host) == "" {
return "MAIL_HOST is not set, so no invitation can be sent"
}
if m.Port <= 0 {
return "MAIL_PORT is not a usable port number"
}
if strings.TrimSpace(m.FromAddress) == "" {
return "MAIL_FROM is not set; an invitation needs a sender address"
}
// Username and password are deliberately NOT required. An internal relay
// that authenticates by network is a real deployment, and demanding
// credentials would refuse it.
if strings.TrimSpace(m.ConsoleURL) == "" {
return "MAIL_CONSOLE_URL is not set; the invitation would have nowhere to point"
}
return ""
}
// Address is host:port, as the SMTP client wants it.
func (m MailConfig) Address() string { return fmt.Sprintf("%s:%d", m.Host, m.Port) }
// InviteLink is where an invitation sends somebody.
//
// Built here rather than in the mailer so the shape is decided once, beside the
// console URL it depends on. The token is the whole credential, so it is the
// only thing in the query string — never an email address or a userid, which
// would put both halves of an account into a URL that lands in server logs,
// browser history and whatever proxy sits between.
func (m MailConfig) InviteLink(token string) string {
base := strings.TrimRight(strings.TrimSpace(m.ConsoleURL), "/")
return base + "/set-password?t=" + token
}
// MailFromEnv reads the mail settings.
func MailFromEnv() MailConfig {
port, err := strconv.Atoi(strings.TrimSpace(env("MAIL_PORT", "587")))
if err != nil {
// Zero rather than the default, so `Why` reports it instead of the
// server quietly dialling a port nobody asked for.
port = 0
}
return MailConfig{
Host: env("MAIL_HOST", ""),
Port: port,
Username: env("MAIL_USERNAME", ""),
Password: env("MAIL_PASSWORD", ""),
// A name is optional; an address is not.
FromAddress: env("MAIL_FROM", ""),
FromName: env("MAIL_FROM_NAME", "Nearle"),
ConsoleURL: env("MAIL_CONSOLE_URL", "https://app.nearledaily.com"),
}
}