auto mail generation
This commit is contained in:
@@ -74,6 +74,9 @@ type Config struct {
|
||||
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
|
||||
// questions; the tools still work.
|
||||
Assistant AssistantConfig
|
||||
// Mail. Optional: a deployment without it still onboards tenants and reports
|
||||
// the invitation as unsent.
|
||||
Mail MailConfig
|
||||
|
||||
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
||||
// unset, matching utils/postoken.go.
|
||||
@@ -357,6 +360,7 @@ func Load() (*Config, error) {
|
||||
},
|
||||
|
||||
Assistant: AssistantFromEnv(),
|
||||
Mail: MailFromEnv(),
|
||||
|
||||
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
||||
JWTSecret: env("JWT_SECRET_KEY", ""),
|
||||
|
||||
106
config/mail.go
Normal file
106
config/mail.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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"),
|
||||
}
|
||||
}
|
||||
36
config/mail_test.go
Normal file
36
config/mail_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// Confirms docs/MAIL_SETUP.md is telling the truth about the committed `.env`:
|
||||
// a sender is set, a host is not, and the server therefore reports mail OFF with
|
||||
// a reason naming the variable — rather than trying and failing to send.
|
||||
func TestCommittedEnvLeavesMailOffWithAReason(t *testing.T) {
|
||||
t.Setenv("MAIL_HOST", "")
|
||||
t.Setenv("MAIL_PORT", "587")
|
||||
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_FROM_NAME", "Nearle")
|
||||
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||
|
||||
cfg := MailFromEnv()
|
||||
if cfg.Enabled() {
|
||||
t.Fatal("mail reported as enabled with no host")
|
||||
}
|
||||
if cfg.Why() == "" || cfg.Why()[:9] != "MAIL_HOST" {
|
||||
t.Fatalf("the reason does not name the missing variable: %q", cfg.Why())
|
||||
}
|
||||
|
||||
// And with the Postal host supplied from .env.secrets, it comes on and the
|
||||
// link points at the MERCHANT console.
|
||||
t.Setenv("MAIL_HOST", "postal.nearledaily.com")
|
||||
on := MailFromEnv()
|
||||
if !on.Enabled() {
|
||||
t.Fatalf("still off with a host set: %s", on.Why())
|
||||
}
|
||||
if got := on.InviteLink("i1.abc.def"); got != "https://app.nearledaily.com/set-password?t=i1.abc.def" {
|
||||
t.Fatalf("the invitation would point at %q", got)
|
||||
}
|
||||
if on.Address() != "postal.nearledaily.com:587" {
|
||||
t.Fatalf("wrong SMTP address: %q", on.Address())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user