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"), } }