feat(admin): self-hosted customer accounts with email verification

Mirrors the pattern sitesvc already proves: 32 random bytes, only the
SHA-256 hash stored, a 24-hour expiry, and the token cleared on use -- so a
leaked database yields no working links.

Unverified login returns a distinct "verify your email address first" rather
than the generic error. The address is already known to be theirs, so there
is nothing to disclose and that is the only useful thing to say.

Licence blobs are emailed inline. A blob is signed public data, not a
secret: it is useless on any instance other than the one it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:07:47 +01:00
co-authored by Claude Opus 5
parent 769839a70d
commit 07a3756b18
3 changed files with 197 additions and 3 deletions
+55
View File
@@ -0,0 +1,55 @@
// Package mail delivers verification links and licence files.
package mail
import (
"fmt"
"net/smtp"
"strings"
)
type Config struct {
Host, Port, From, Username, Password string
PublicURL string
}
var cfg Config
func Init(c Config) { cfg = c }
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
func send(to, subject, body string) error {
if !Enabled() {
return fmt.Errorf("SMTP is not configured")
}
msg := strings.Join([]string{
"From: " + cfg.From,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"", body,
}, "\r\n")
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg))
}
func SendVerification(to, token string) error {
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
return send(to, "Verify your Vantage account",
"Confirm your email address to finish setting up your Vantage account:\n\n"+
link+"\n\nThis link expires in 24 hours.\n")
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
return send(to, "Your Vantage licence key",
fmt.Sprintf("Your licence for %s is below.\n\n"+
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
instanceName, blob))
}