154 lines
5.9 KiB
Go
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
|
|
}
|