Files
backend_fiesta/config/mail.go
2026-09-30 14:58:30 +05:30

154 lines
5.9 KiB
Go

package config
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// 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: smtpPassword(env("MAIL_HOST", ""), 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"),
}
}
/*
smtpPassword takes the spaces out of a Google App Password.
Google shows a 16-character App Password formatted for reading — "abcd efgh
ijkl mnop" — and the spaces are presentation, not part of the secret. Pasted
verbatim they survive into the credential and Gmail refuses the login, which
Fiesta reports as "the mail server refused our credentials". That sends somebody
to revoke a perfectly good password and generate another one with the same four
spaces in it.
ONLY for Google's own SMTP hosts, and only when what is left is the 16
alphanumeric characters an App Password actually is. A password is a secret and
quietly editing one is normally the wrong thing: another relay's password may
legitimately contain a space, and stripping it there would turn a working
credential into a silent authentication failure — the exact bug this avoids,
pointed the other way.
*/
func smtpPassword(host, password string) string {
if !isGoogleSMTP(host) {
return password
}
stripped := strings.Join(strings.Fields(password), "")
if stripped == password || len(stripped) != googleAppPasswordLength {
return password
}
for _, r := range stripped {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return password
}
}
return stripped
}
// googleAppPasswordLength is what Google issues: sixteen characters, shown in
// four groups of four.
const googleAppPasswordLength = 16
func isGoogleSMTP(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "smtp.gmail.com", "smtp-relay.gmail.com", "aspmx.l.google.com":
return true
}
return false
}